├── .gitignore ├── LICENSE ├── README.MD ├── pom.xml └── src └── main ├── java └── org │ └── spmart │ └── ytapbot │ ├── Bot.java │ ├── Main.java │ ├── util │ ├── Logger.java │ ├── ProcessExecutor.java │ ├── UrlNormalizer.java │ └── UrlValidator.java │ └── youtube │ ├── AudioInfo.java │ ├── AudioSlicer.java │ ├── Downloader.java │ └── Query.java └── resources └── META-INF └── MANIFEST.MF /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /audio/ 3 | /src/main/java/testthings/ 4 | /name.txt 5 | /token.txt 6 | /YouTubeADLBot.iml 7 | /bot.log 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Spmart 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # YTAP 2 | 3 | Telegram bot that downloads audio from YouTube and sends it to the user as an audio file. [You can try it now!](http://t.me/ytap_bot) 4 | 5 | ![Hello, YTAP!](https://i.imgur.com/tgFdsjU.png) 6 | 7 | ## Dependencies and requirements 8 | * Unix-like OS — Ubuntu, Debian, CentOS or macOS — that's all should work; 9 | * JDK 11 is minimal *(I guess... Actually I'm using AdoptOpenJDK 14)*; 10 | * Maven; 11 | * [Telgrambots](https://github.com/rubenlagus/TelegramBots) library; 12 | * [YouTube-DL](https://github.com/ytdl-org/youtube-dl) installed; 13 | * [FFmpeg](https://github.com/FFmpeg/FFmpeg) installed; 14 | 15 | ## Getting started 16 | 0\. Install JDK. I use [AdoptOpenJDK14](https://adoptopenjdk.net) with OpenJ9 VM. 17 | 18 | 1\. Clone repository. If you are using an IntelliJ IDEA, you can grab all project directly from GitHub by link. 19 | 20 | 2\. Add **telegrambots** dependency in your *pom.xml*: 21 | ```xml 22 | 23 | org.telegram 24 | telegrambots 25 | 4.9 26 | 27 | ``` 28 | 29 | I also recommend using compiler source and target for Maven: 30 | ```xml 31 | 32 | 14 33 | 14 34 | 35 | ``` 36 | 37 | 3\. Create files **name.txt** and **token.txt** in working directory. Add a bot name and token, according to the file names. If you don't have it ask [*@BotFather*](https://core.telegram.org/bots#6-botfather) at Telegram. 38 | 39 | 4\. Create an empty **./audio** directory. 40 | 41 | The end result should be something like this: 42 | ![Directory test things isn't required](https://i.imgur.com/XghsLqB.png) 43 | 44 | 5\. Install youtube-dl. You can do that from system package manager, but packages may be quite old (except *brew* on Mac and *pacman* in Arch). [Here](https://github.com/ytdl-org/youtube-dl#installation) is a better way to install the latest version. 45 | *Make sure, that you can call youtube-dl from /usr/local/bin/youtube-dl. If not, [create a symlink](https://askubuntu.com/questions/56339/how-to-create-a-soft-or-symbolic-link).* 46 | 47 | 6\. Install FFmpeg. You can use your system package manager here. 48 | 49 | 7\. Build the project with any available method. I use an artifact (JAR) [build with IntelliJ IDEA](https://stackoverflow.com/questions/1082580/how-to-build-jars-from-intellij-properly). 50 | 51 | 8\. **[Optional]** If you deploy your bot to VPS, you probably want to run it in the background. You can use [nohup](https://linux.die.net/man/1/nohup) for that, but better way would be a **Systemd** service. 52 | 53 | 1. Create in *\*.service* file in */etc/systemd/system/*. For example, it would be *ytap.service*. 54 | 2. Your service may look like that: 55 | ``` 56 | [Unit] 57 | Description=Manage Java ytap service 58 | [Service] 59 | WorkingDirectory=/var/www/telegrambots/ytap/ 60 | ExecStart=/bin/java -jar YTAP.jar 61 | User=ytap 62 | Type=simple 63 | Restart=on-failure 64 | RestartSec=10 65 | [Install] 66 | WantedBy=multi-user.target 67 | ``` 68 | 3. Save ytap.service file, restart systemd, enable service and start it: 69 | ```bash 70 | $ sudo systemctl daemon-reload 71 | $ sudo systemctl enable ytap.service 72 | $ sudo systemctl start ytap.service 73 | ``` 74 | Now your bot runs in background, automatically restarts on failure and after server reboot. 75 | 76 | 9\. **[Optional]** Add a cron job to regularly update youtube-dl. This will prevent errors related to YouTube changes. The cron job looks like this: 77 | 78 | ``` 79 | 0 3 * * * /usr/local/bin/youtube-dl -U >> /var/www/telegrambots/ytap/bot.log 80 | ``` 81 | 82 | If there is a problem with the update, the information will be logged. -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | org.spmart 8 | YouTubeADLBot 9 | 1.0-SNAPSHOT 10 | 11 | 12 | 13 | org.telegram 14 | telegrambots 15 | 4.9 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/main/java/org/spmart/ytapbot/Bot.java: -------------------------------------------------------------------------------- 1 | package org.spmart.ytapbot; 2 | 3 | import org.spmart.ytapbot.util.UrlNormalizer; 4 | import org.spmart.ytapbot.util.UrlValidator; 5 | import org.spmart.ytapbot.util.Logger; 6 | 7 | import org.spmart.ytapbot.youtube.AudioInfo; 8 | import org.spmart.ytapbot.youtube.AudioSlicer; 9 | import org.spmart.ytapbot.youtube.Downloader; 10 | import org.telegram.telegrambots.bots.TelegramLongPollingBot; 11 | import org.telegram.telegrambots.meta.api.methods.send.SendAudio; 12 | import org.telegram.telegrambots.meta.api.methods.send.SendMessage; 13 | import org.telegram.telegrambots.meta.api.objects.Update; 14 | import org.telegram.telegrambots.meta.exceptions.TelegramApiException; 15 | 16 | import java.io.File; 17 | import java.io.IOException; 18 | import java.nio.charset.StandardCharsets; 19 | import java.nio.file.Files; 20 | import java.nio.file.Path; 21 | import java.nio.file.Paths; 22 | import java.util.List; 23 | 24 | public class Bot extends TelegramLongPollingBot { 25 | 26 | private static final String PATH_TO_TOKEN = "token.txt"; // Token from @BotFather 27 | private static final String PATH_TO_NAME = "name.txt"; // Bot username, for ex: ytap_bot 28 | private static final int MAX_AUDIO_DURATION = 18000; // 5 hours in seconds 29 | private static final int MAX_AUDIO_CHUNK_DURATION = 3000; // 50 minutes in seconds 30 | 31 | private static final String START_MESSAGE = "Hello friend! Send me a YouTube link! I'll return you an audio from it."; 32 | 33 | /** 34 | * If bot receiving a message, starts new thread to process it. 35 | * @param update Update from Telegram Bot API. 36 | */ 37 | public void onUpdateReceived(Update update) { 38 | new Thread(() -> processMessage(update)).start(); 39 | } 40 | 41 | public String getBotUsername() { 42 | return getTextFromFile(PATH_TO_NAME); 43 | } 44 | 45 | public String getBotToken() { 46 | return getTextFromFile(PATH_TO_TOKEN); 47 | } 48 | 49 | /** 50 | * Process the message that user sent to the bot 51 | * @param update Update from Telegram Bot API. 52 | */ 53 | private void processMessage(Update update) { 54 | // We check if the update has a message and the message has text 55 | if (update.hasMessage() && update.getMessage().hasText()) { 56 | Long chatId = update.getMessage().getChatId(); 57 | Integer messageId = update.getMessage().getMessageId(); // is unique, incremental 58 | String inMessageText = update.getMessage().getText(); 59 | 60 | UrlValidator validator = new UrlValidator(); 61 | UrlNormalizer normalizer = new UrlNormalizer(); 62 | 63 | // logic: try to cut args only in url, we shouldn't touch plain text 64 | if (validator.isUrl(inMessageText) && 65 | validator.isValidYouTubeVideoUrl(normalizer.deleteArgsFromYouTubeUrl(inMessageText))) { 66 | 67 | String normalizedUrl = normalizer.deleteArgsFromYouTubeUrl(inMessageText); 68 | String audioPath = String.format("./audio/%s.m4a", messageId); 69 | Downloader downloader = new Downloader(normalizedUrl, audioPath); 70 | AudioInfo info = downloader.getAudioInfo(); 71 | 72 | if (!info.isAvailable() && info.getDuration() <= 0) { // Streams are not available for us, but sometimes stream contains info with zero in video duration 73 | send(chatId, "Seems like you send a link to stream that's not finished yet. " + 74 | "If so, try again later when the broadcast is over."); 75 | } else if (!info.isAvailable()) { 76 | send(chatId, "Audio is not available. Possible reasons:\n " + 77 | "1. Broken link.\n " + 78 | "2. YouTube marked this video as unacceptable for some users.\n " + 79 | "3. Private video.\n " + 80 | "4. This is a link to stream that's not finished yet. If so, try again later."); // Some streams are not provide any info at all 81 | } else if (info.getDuration() > MAX_AUDIO_DURATION) { 82 | send(chatId, "It's too long video! My maximum is 5 hours."); 83 | } else if (info.getDuration() > MAX_AUDIO_CHUNK_DURATION) { 84 | send(chatId, "Downloading and slicing for parts..."); 85 | downloader.getAudio(); 86 | AudioSlicer slicer = new AudioSlicer(info); 87 | List partsInfo = slicer.getAudioParts(MAX_AUDIO_CHUNK_DURATION); 88 | sendAll(chatId, partsInfo); 89 | deleteAudio(info); // cleanup 90 | deleteAudio(partsInfo); 91 | } else { 92 | send(chatId, "Downloading..."); 93 | downloader.getAudio(); 94 | send(chatId, info); 95 | deleteAudio(info); // cleanup 96 | } 97 | 98 | } else if (inMessageText.equals("/start")) { 99 | send(chatId, START_MESSAGE); 100 | } else { 101 | send(chatId, "It's NOT a YouTube link! Try again."); 102 | } 103 | } 104 | } 105 | 106 | private String getTextFromFile(String path) { 107 | String content = ""; 108 | try { 109 | content = Files.readString(Paths.get(path), StandardCharsets.UTF_8); 110 | } catch (IOException e) { 111 | Logger.INSTANCE.write(String.format("File not found! %s", e.getMessage())); 112 | } 113 | return content.trim(); // Trim string for spaces or new lines 114 | } 115 | 116 | /** 117 | * Sends a text message to user. 118 | * @param chatId Unique ID that identifies the chat to the user. 119 | * @param text Text message. 120 | */ 121 | private void send(long chatId, String text) { 122 | SendMessage message = new SendMessage(); 123 | message.setChatId(chatId).setText(text); 124 | try { 125 | execute(message); // Call method to send the message 126 | } catch (TelegramApiException e) { 127 | Logger.INSTANCE.write(String.format("Error! Can't send a message! %s", e.getMessage())); 128 | } 129 | } 130 | 131 | /** 132 | * Sends a message with an audio record. 133 | * @param chatId Unique ID that identifies the chat to the user. 134 | * @param info AudioInfo object that contains title, duration, caption and audio path. 135 | */ 136 | private void send(long chatId, AudioInfo info) { 137 | File audioFile = new File(info.getPath()); 138 | if (audioFile.exists()) { 139 | SendAudio audio = new SendAudio(); 140 | audio 141 | .setTitle(info.getTitle()) 142 | .setDuration(info.getDuration()) 143 | .setCaption(info.getTitle()) 144 | .setChatId(chatId) 145 | .setAudio(audioFile); 146 | try { 147 | execute(audio); 148 | } catch (TelegramApiException e) { 149 | Logger.INSTANCE.write(String.format("Error! Can't send an audio! %s", e.getMessage())); 150 | send(chatId, "Can't send an audio. May be, file is bigger than 50 MB"); // Double check. If Telegram API stops upload 151 | } 152 | } else { 153 | send(chatId, "Error downloading audio. This case will be reported to bot developer."); 154 | Logger.INSTANCE.write("Download error. Most likely, audio is removed before sending."); // This error shouldn't appear in other cases. Streams are handled earlier. I need more data. 155 | Logger.INSTANCE.write("Audio: " + info.getTitle() + " " + info.getPath()); 156 | } 157 | } 158 | 159 | /** 160 | * Sends all audio. Each audio in a separate message. 161 | * @param chatId Unique ID that identifies the chat to the user. 162 | * @param infos Collection with AudioInfo objects that contains title, duration, caption and audio path. 163 | */ 164 | private void sendAll(long chatId, List infos) { 165 | for (AudioInfo info : infos) { 166 | send(chatId, info); 167 | } 168 | } 169 | 170 | 171 | /** 172 | * Removes audio from HDD. 173 | * @param info AudioInfo object that contains title, duration, caption and audio path. 174 | */ 175 | private void deleteAudio(AudioInfo info) { 176 | Path pathToAudio = Path.of(info.getPath()); 177 | try { 178 | Files.deleteIfExists(pathToAudio); 179 | } catch (IOException e) { 180 | Logger.INSTANCE.write(String.format("Delete error! File is not exist or busy. %s", e.getMessage())); 181 | } 182 | } 183 | 184 | 185 | /** 186 | * Removes all audio parts from HDD. 187 | * @param infos Collection with AudioInfo objects that contains title, duration, caption and audio path. 188 | */ 189 | private void deleteAudio(List infos) { 190 | for (AudioInfo info : infos) { 191 | deleteAudio(info); 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/main/java/org/spmart/ytapbot/Main.java: -------------------------------------------------------------------------------- 1 | package org.spmart.ytapbot; 2 | 3 | import org.spmart.ytapbot.util.Logger; 4 | import org.telegram.telegrambots.ApiContextInitializer; 5 | import org.telegram.telegrambots.meta.TelegramBotsApi; 6 | import org.telegram.telegrambots.meta.exceptions.TelegramApiException; 7 | 8 | public class Main { 9 | public static void main(String[] args) { 10 | 11 | ApiContextInitializer.init(); 12 | TelegramBotsApi telegramBotsApi = new TelegramBotsApi(); 13 | 14 | Logger logger = Logger.INSTANCE; 15 | 16 | logger.write("YOUTUBE AUDIO PODCASTER IS STARTED!"); 17 | try { 18 | telegramBotsApi.registerBot(new Bot()); 19 | } catch (TelegramApiException e) { 20 | logger.write("Can't start! Check your network connection."); 21 | e.printStackTrace(); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/org/spmart/ytapbot/util/Logger.java: -------------------------------------------------------------------------------- 1 | package org.spmart.ytapbot.util; 2 | 3 | import java.io.File; 4 | import java.io.IOException; 5 | import java.nio.file.Files; 6 | import java.nio.file.Path; 7 | import java.nio.file.Paths; 8 | import java.nio.file.StandardOpenOption; 9 | import java.time.ZonedDateTime; 10 | import java.time.format.DateTimeFormatter; 11 | 12 | /** 13 | * Logger singleton. 14 | * Log4j is overkill for such a little project like that. 15 | */ 16 | public enum Logger { 17 | INSTANCE; 18 | 19 | // If project built in JAR, log file will be created in same working dir, with JAR. 20 | private static final Path PATH_TO_LOG = Paths.get("bot.log"); 21 | 22 | /** 23 | * Write a string into a log file. 24 | * @param message Message for logging. 25 | */ 26 | public void write(String message) { 27 | File logFile = new File(PATH_TO_LOG.toString()); 28 | try { 29 | logFile.createNewFile(); 30 | } catch (IOException e) { 31 | System.out.println(e.getMessage()); 32 | } 33 | 34 | message = String.format("%s %s\n", getTimeStamp(), message); 35 | 36 | try { 37 | Files.write(PATH_TO_LOG, message.getBytes(), StandardOpenOption.APPEND); 38 | } catch (IOException e) { 39 | System.out.println(e.getMessage()); 40 | } 41 | } 42 | 43 | /** 44 | * Get date and time in nice format like: 45 | * Wed, 10 Jun 2020 09:26:04 +0300 YTAP IS STARTED! 46 | * @return Current local date/time. 47 | */ 48 | private String getTimeStamp() { 49 | return ZonedDateTime.now().format(DateTimeFormatter.RFC_1123_DATE_TIME); 50 | } 51 | } -------------------------------------------------------------------------------- /src/main/java/org/spmart/ytapbot/util/ProcessExecutor.java: -------------------------------------------------------------------------------- 1 | package org.spmart.ytapbot.util; 2 | 3 | import java.io.BufferedReader; 4 | import java.io.File; 5 | import java.io.IOException; 6 | import java.io.InputStreamReader; 7 | import java.util.ArrayList; 8 | import java.util.List; 9 | import java.util.concurrent.TimeUnit; 10 | 11 | /** 12 | * Builds and executes shell process. 13 | */ 14 | public class ProcessExecutor { 15 | 16 | /** 17 | * Runs new process with bash -c command. Dies in 300 seconds, if hangs. 18 | * @param cmdline Command to execute with bash. 19 | * @param workingDirectory Directory from which the process will be launched 20 | * @return Process output with an exit code. 21 | */ 22 | public List runProcess(final String cmdline, final String workingDirectory) { 23 | final String IO_EXCEPTION_CODE = "42"; 24 | final String INTERRUPTED_EXCEPTION_CODE = "44"; 25 | final int TIMEOUT = 300; // Timeout in seconds. After timeout process must die. 26 | 27 | final String SHELL = "bash"; 28 | final String SHELL_ARGS = "-c"; 29 | 30 | List processOutput = new ArrayList<>(); 31 | try { 32 | Process process = new ProcessBuilder(SHELL, SHELL_ARGS, cmdline) 33 | .redirectErrorStream(true) 34 | .directory(new File(workingDirectory)) 35 | .start(); 36 | 37 | BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream())); 38 | 39 | String line; 40 | while ((line = br.readLine()) != null) { 41 | processOutput.add(line); 42 | } 43 | 44 | process.waitFor(TIMEOUT, TimeUnit.SECONDS); // Start process 45 | processOutput.add(String.valueOf(process.exitValue())); // Always add an exit code in the end of output 46 | 47 | } catch (IOException e) { 48 | processOutput.add(String.format("I/O exception occurred when running command: %s", cmdline)); 49 | processOutput.add(e.getMessage()); 50 | processOutput.add(IO_EXCEPTION_CODE); // Because 51 | 52 | } catch (InterruptedException e) { 53 | processOutput.add(String.format("Interrupted exception occurred when running command: %s", cmdline)); 54 | processOutput.add(e.getMessage()); 55 | processOutput.add(INTERRUPTED_EXCEPTION_CODE); 56 | } 57 | 58 | return processOutput; 59 | } 60 | 61 | public List runProcess(final String cmdline) { 62 | return runProcess(cmdline, "./"); 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/org/spmart/ytapbot/util/UrlNormalizer.java: -------------------------------------------------------------------------------- 1 | package org.spmart.ytapbot.util; 2 | 3 | public class UrlNormalizer { 4 | /** 5 | * @param url Valid URL. 6 | * @return Url without arguments after "&" char. 7 | */ 8 | public String deleteArgsFromYouTubeUrl(String url) { 9 | return url.split("&")[0]; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/org/spmart/ytapbot/util/UrlValidator.java: -------------------------------------------------------------------------------- 1 | package org.spmart.ytapbot.util; 2 | 3 | import java.net.MalformedURLException; 4 | import java.net.URL; 5 | 6 | public class UrlValidator { 7 | private static final String youTubeExp = "http(?:s?):\\/\\/((?:www\\.)|(?:m\\.))?youtu(?:be\\.com\\/watch\\?v=|\\.be\\/)([\\w\\-\\_]*)(&(amp;)?[\\w\\?\u200C\u200B=]*)?"; 8 | 9 | public boolean isUrl(String url) { 10 | try { 11 | new URL(url); // if string is not URL, a wild exception appears! 12 | return true; 13 | } catch (MalformedURLException e) { // gotta catch 'em all! 14 | return false; 15 | } 16 | } 17 | 18 | public boolean isValidYouTubeVideoUrl(String url) { 19 | return url.matches(youTubeExp); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/org/spmart/ytapbot/youtube/AudioInfo.java: -------------------------------------------------------------------------------- 1 | package org.spmart.ytapbot.youtube; 2 | 3 | public class AudioInfo { 4 | private String title; 5 | private int duration; 6 | private String path; 7 | private boolean isAvailable; 8 | 9 | /** 10 | * Info about audio that received from youtube-dl. 11 | */ 12 | public AudioInfo() { 13 | this("No title"); 14 | } 15 | 16 | public AudioInfo(String title) { 17 | this(title, 0); 18 | } 19 | 20 | /** 21 | * Info about audio that received from youtube-dl. 22 | * @param title Audio title. 23 | * @param duration Audio duration in seconds. 24 | */ 25 | public AudioInfo(String title, int duration) { 26 | this(title, duration, ""); 27 | } 28 | 29 | /** 30 | * Info about audio that received from youtube-dl. 31 | * @param title Audio title. 32 | * @param duration Audio duration in seconds. 33 | * @param path Path to audio file. Could be relative or absolute. 34 | */ 35 | public AudioInfo(String title, int duration, String path) { 36 | this(title, duration, path, false); 37 | } 38 | /** 39 | * Info about audio that received from youtube-dl. 40 | * @param title Audio title. 41 | * @param duration Audio duration in seconds. 42 | * @param path Path to audio file. Could be relative or absolute. 43 | * @param isAvailable Is available to download. "False" by default. 44 | */ 45 | public AudioInfo(String title, int duration, String path, boolean isAvailable) { 46 | this.title = title; 47 | this.duration = duration; 48 | this.path = path; 49 | this.isAvailable = isAvailable; 50 | } 51 | 52 | public String getPath() { 53 | return path; 54 | } 55 | 56 | public void setPath(String path) { 57 | this.path = path; 58 | } 59 | 60 | public String getTitle() { 61 | return title; 62 | } 63 | 64 | public int getDuration() { 65 | return duration; 66 | } 67 | 68 | public void setTitle(String title) { 69 | this.title = title; 70 | } 71 | 72 | public void setDuration(int duration) { 73 | this.duration = duration; 74 | } 75 | 76 | public boolean isAvailable() { 77 | return isAvailable; 78 | } 79 | 80 | public void setAvailability(boolean available) { 81 | this.isAvailable = available; 82 | } 83 | } -------------------------------------------------------------------------------- /src/main/java/org/spmart/ytapbot/youtube/AudioSlicer.java: -------------------------------------------------------------------------------- 1 | package org.spmart.ytapbot.youtube; 2 | 3 | import org.spmart.ytapbot.util.ProcessExecutor; 4 | 5 | import java.util.ArrayList; 6 | import java.util.List; 7 | 8 | public class AudioSlicer { 9 | private final AudioInfo originalAudio; 10 | 11 | private final String SLICE_CMD = "ffmpeg -hide_banner -i '%s' -reset_timestamps 1 -f segment -segment_time %d -c copy '%s'"; // -reset_timestamps is necessary 12 | private final String PART_NAME_FORMAT = "_part%01d.m4a"; // 1234_part0.m4a, 1234_part1.m4a ... etc 13 | 14 | /** 15 | * Slices an audio on parts (fragments) specified length. 16 | * @param audioInfo AudioInfo object that contains title, duration, caption and audio path. 17 | */ 18 | public AudioSlicer(AudioInfo audioInfo) { 19 | originalAudio = audioInfo; 20 | } 21 | 22 | /** 23 | * Returns a list of AudioInfo's. Every item contains info about audio fragment. 24 | * @param fragmentDuration Specified audio fragment length. Last fragment length calculates automatically. 25 | * @return A list of AudioInfo's. 26 | */ 27 | public List getAudioParts(int fragmentDuration) { 28 | final String OPENING_FOR_WRITING_EXP = ".*Opening '.*' for writing$"; 29 | 30 | ProcessExecutor processExecutor = new ProcessExecutor(); 31 | String originalAudioPath = originalAudio.getPath(); 32 | List processOutput = processExecutor.runProcess(String.format(SLICE_CMD, originalAudioPath, fragmentDuration, cutExtension(originalAudioPath))); 33 | 34 | List fragmentPaths = new ArrayList<>(); 35 | for (String str : processOutput) { 36 | if (str.matches(OPENING_FOR_WRITING_EXP)) { // parsing ffmpeg out strings like: "[segment @ 0x7ff3ca808200] Opening '/Users/kai/Downloads/12341.m4a' for writing" 37 | fragmentPaths.add(substringBetween(str, "'", "'")); // grab path to fragment 38 | } 39 | } 40 | 41 | int lastFragmentDuration = originalAudio.getDuration() - ((fragmentPaths.size() - 1) * fragmentDuration); // calc n-1 fragments length and find duration of last audio 42 | List fragments = new ArrayList<>(); 43 | for (int partNumber = 0; partNumber < fragmentPaths.size(); partNumber++) { 44 | String fragmentName = originalAudio.getTitle() + String.format(" Part %d", partNumber); 45 | if (partNumber == fragmentPaths.size() - 1) { 46 | fragments.add(new AudioInfo(fragmentName, lastFragmentDuration, fragmentPaths.get(partNumber), true)); // if last path in list, we should set calculated duration 47 | } else { 48 | fragments.add(new AudioInfo(fragmentName, fragmentDuration, fragmentPaths.get(partNumber), true)); // if not last element, use default fragment duration 49 | } 50 | } 51 | 52 | return fragments; 53 | } 54 | 55 | private String cutExtension(String audioPath) { 56 | return audioPath.substring(0, audioPath.length() - 4) + PART_NAME_FORMAT; 57 | } 58 | 59 | private String substringBetween(String str, String open, String close) { // stolen, it's shame (a little) 60 | final int INDEX_NOT_FOUND = -1; 61 | 62 | final int start = str.indexOf(open); 63 | if (start != INDEX_NOT_FOUND) { 64 | final int end = str.indexOf(close, start + open.length()); 65 | if (end != INDEX_NOT_FOUND) { 66 | return str.substring(start + open.length(), end); 67 | } 68 | } 69 | return ""; 70 | } 71 | } 72 | 73 | -------------------------------------------------------------------------------- /src/main/java/org/spmart/ytapbot/youtube/Downloader.java: -------------------------------------------------------------------------------- 1 | package org.spmart.ytapbot.youtube; 2 | 3 | import org.spmart.ytapbot.util.Logger; 4 | import org.spmart.ytapbot.util.ProcessExecutor; 5 | 6 | import java.util.List; 7 | 8 | public class Downloader { 9 | private final String YOUTUBE_URL; 10 | private final String DOWNLOAD_PATH; 11 | 12 | private final String FILE_TYPE_KEY = "-f"; 13 | private final String OUTPUT_KEY = "-o"; 14 | 15 | private final String ONLY_AUDIO_M4A_OPTION = "bestaudio[ext=m4a]"; 16 | private final String GET_TITLE_OPTION = "--get-title"; 17 | private final String GET_DURATION_OPTION = "--get-duration"; 18 | 19 | /** 20 | * Prepares download. Can download information about audio and audio itself. 21 | * @param youTubeUrl Valid URL to YouTube video. Should be https://www.youtube.com/watch?v=xxx or https://youtu.be/xxx 22 | * @param downloadPath Path to output file. 23 | */ 24 | public Downloader(String youTubeUrl, String downloadPath) { 25 | this.YOUTUBE_URL = youTubeUrl; 26 | this.DOWNLOAD_PATH = downloadPath; 27 | } 28 | 29 | /** 30 | * Downloads an audio. No duration or any other check. 31 | * @return True if audio is downloaded. False if the audio download is failed 32 | */ 33 | public boolean getAudio() { 34 | Query query = new Query(YOUTUBE_URL); 35 | query.setOption(FILE_TYPE_KEY, ONLY_AUDIO_M4A_OPTION); 36 | query.setOption(OUTPUT_KEY, DOWNLOAD_PATH); 37 | 38 | String cmdLine = query.toString(); 39 | 40 | ProcessExecutor processExecutor = new ProcessExecutor(); 41 | List processOutput = processExecutor.runProcess(cmdLine); // Actually, here downloading is starting 42 | 43 | Logger logger = Logger.INSTANCE; 44 | 45 | if (!isDownloaded(processOutput)) { // if process error code != 0 46 | logger.write(String.format("Download %s is failed. Youtube-dl output:", YOUTUBE_URL)); 47 | processOutput.forEach(logger::write); // Write all youtube-dl out into a bot log 48 | return false; 49 | } 50 | return true; 51 | } 52 | 53 | @Deprecated 54 | public String getTitle() { 55 | Query query = new Query(YOUTUBE_URL); 56 | query.setOption(GET_TITLE_OPTION); 57 | 58 | String cmdLine = query.toString(); 59 | 60 | ProcessExecutor processExecutor = new ProcessExecutor(); 61 | List processOutput = processExecutor.runProcess(cmdLine); 62 | 63 | if (isDownloaded(processOutput)) { 64 | return processOutput.get(0); 65 | } else { 66 | return "No Title"; // In the end of the Downloader always an exit code 67 | } 68 | } 69 | 70 | /** 71 | * Sends one query to YouTube API to grab all information about audio. 72 | * @return AudioInfo instance that contains title and duration. 73 | */ 74 | public AudioInfo getAudioInfo() { 75 | Query query = new Query(YOUTUBE_URL); 76 | query.setOption(GET_TITLE_OPTION); 77 | query.setOption(GET_DURATION_OPTION); 78 | 79 | String cmdLine = query.toString(); 80 | 81 | ProcessExecutor processExecutor = new ProcessExecutor(); 82 | List processOutput = processExecutor.runProcess(cmdLine); 83 | 84 | AudioInfo info = new AudioInfo(); 85 | if (isDownloaded(processOutput)) { 86 | info.setTitle(processOutput.get(0)); 87 | info.setPath(DOWNLOAD_PATH); 88 | info.setDuration(convertDurationToSeconds(processOutput.get(1))); 89 | } 90 | 91 | /* 92 | Workaround for stream detection. YouTube return 0, if stream is running now. 93 | Not guarantied. Often YouTube doesn't provide any info about stream at all 94 | */ 95 | if (info.getDuration() > 0) { 96 | info.setAvailability(true); // false by default 97 | } 98 | return info; 99 | } 100 | 101 | /** 102 | * @param duration Duration in HH:MM:SS, MM:SS or SS format. 103 | * @return Duration in seconds. 104 | */ 105 | private int convertDurationToSeconds(String duration) { 106 | String[] durationHms = duration.split(":"); //Hours, minutes, seconds 107 | return switch (durationHms.length) { 108 | case 1 -> Integer.parseInt(durationHms[0]); 109 | case 2 -> Integer.parseInt(durationHms[0]) * 60 + Integer.parseInt(durationHms[1]); 110 | case 3 -> Integer.parseInt(durationHms[0]) * 3600 111 | + Integer.parseInt(durationHms[1]) * 60 + Integer.parseInt(durationHms[2]); 112 | default -> 0; 113 | }; 114 | } 115 | 116 | private boolean isDownloaded(List processOutput) { 117 | return processOutput.get(processOutput.size() - 1).equals("0"); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/main/java/org/spmart/ytapbot/youtube/Query.java: -------------------------------------------------------------------------------- 1 | package org.spmart.ytapbot.youtube; 2 | 3 | import java.util.HashMap; 4 | import java.util.Map; 5 | 6 | public class Query { 7 | private String youTubeUrl; 8 | private Map options; 9 | 10 | /** 11 | * Builds a query to YouTube, using youtube-dl. 12 | * @param youTubeUrl Valid URL to YouTube video. Should be https://www.youtube.com/watch?v=xxx or https://youtu.be/xxx 13 | */ 14 | public Query(String youTubeUrl) { 15 | this.youTubeUrl = youTubeUrl; 16 | options = new HashMap<>(); 17 | } 18 | 19 | public String getYouTubeUrl() { 20 | return youTubeUrl; 21 | } 22 | 23 | public void setYouTubeUrl(String youTubeUrl) { 24 | this.youTubeUrl = youTubeUrl; 25 | } 26 | 27 | /** 28 | * Sets a pair of option-value. Example: -f bestaudio. 29 | * @param option Key in format -f or --foo 30 | * @param value Value, can be a string. Format depends from option itself. 31 | */ 32 | public void setOption(String option, String value) { 33 | options.put(option, value); 34 | } 35 | 36 | /** 37 | * Sets a single option. 38 | * @param option It's can be a key like -f or --foo. 39 | */ 40 | public void setOption(String option) { 41 | setOption(option, ""); 42 | } 43 | 44 | /** 45 | * @return Ready to execute bash command for YouTube query. 46 | */ 47 | @Override 48 | public String toString() { 49 | final String YOUTUBE_DL_BIN = "/usr/local/bin/youtube-dl"; 50 | 51 | StringBuilder stringBuilder = new StringBuilder(); 52 | stringBuilder.append(YOUTUBE_DL_BIN); 53 | stringBuilder.append(" "); 54 | 55 | for (String key : options.keySet()) { 56 | String value = options.get(key); 57 | stringBuilder.append(String.format("%s ", key)); 58 | if (!value.isEmpty()) { // if value is not empty, then wrap it with '' and append 59 | stringBuilder.append(String.format("'%s' ", value)); // wrap args like '~/Downloads/123456.%(ext)s' 60 | } 61 | } 62 | stringBuilder.append("'").append(youTubeUrl).append("'"); 63 | 64 | /* 65 | in result we can get something like that: 66 | youtube-dl -f 'bestaudio[ext=m4a]' -o '~/Downloads/123456.%(ext)s' 'http://youtu.be/hTvJoYnpeRQ' 67 | */ 68 | 69 | return stringBuilder.toString(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/MANIFEST.MF: -------------------------------------------------------------------------------- 1 | Manifest-Version: 1.0 2 | Main-Class: org.spmart.ytapbot.Main 3 | --------------------------------------------------------------------------------