├── .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 | 
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 | 
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 |
--------------------------------------------------------------------------------