├── .github └── FUNDING.yml ├── CHANGELOG.md ├── README.md ├── _config.yml ├── _updated.log ├── screenshot.png └── source ├── Cache.cs ├── Constants.cs ├── Preparer.cs ├── Program.cs ├── Saver.cs ├── Validator.cs └── Waiter.cs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: payoneer 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [24.061] - 2024-06-22 4 | 5 | ### Fixed 6 | 7 | - Minor fix. 8 | 9 | ## [23.081] - 2023-08-04 10 | 11 | ### Fixed 12 | 13 | - Minor fix. 14 | 15 | ## [23.071] - 2023-07-25 16 | 17 | ### Fixed 18 | 19 | - Fixed YouTube throttling. For faster downloading, use Chromium-based browsers. Attention: updated browsers are not compatible, for example, Chrome only works with version 110 and below. AVC video data is also not available, only VP9. 20 | 21 | ## [22.122] - 2022-12-19 22 | 23 | ### Fixed 24 | 25 | - Several minor fixes. 26 | 27 | ## [22.121] - 2022-12-03 28 | 29 | ### Changed 30 | 31 | - Changed the behavior of `-browser` parameter. See its description for more information. 32 | 33 | ### Fixed 34 | 35 | - Several minor fixes. 36 | 37 | ## [22.062] - 2022-06-22 38 | 39 | ### Fixed 40 | 41 | - Several minor fixes. 42 | 43 | ## [22.061] - 2022-06-06 44 | 45 | ### Added 46 | 47 | - The ability to cache technical information about the live stream. Read about the `-keepstreaminfo` parameter to not use the cache. 48 | 49 | ### Changed 50 | 51 | - Improved and documented `-log` parameter. 52 | 53 | ### Fixed 54 | 55 | - Several minor fixes. 56 | 57 | ## [22.051] - 2022-05-19 58 | 59 | ### Added 60 | 61 | - Downloading of recently finished live streams (no more than 6 hours ago). 62 | - Option `-duration=[minutes].[seconds]`, for better usability. 63 | - Option `-resolution=0` to save audio only (works with both audio and video formats except `.mp4`). 64 | - Options `-duration=max`, `-duration=min`, `-resolution=max`, `-resolution=min`, for better usability. 65 | - Saving to `.m3u` and `.m3u8` formats (allows to play the specified part of the live stream without downloading, only tested with VLC mediaplayer). 66 | 67 | ### Changed 68 | 69 | - Default media container format, from `.mp4` to `.mkv`. 70 | 71 | ### Fixed 72 | 73 | - Several minor fixes. 74 | 75 | ### Removed 76 | 77 | - Option `-start=wait` (now the program is waiting for live streams without this option). 78 | 79 | ## [22.041] - 2022-04-14 80 | 81 | ### Added 82 | 83 | - Parameter `-browser` to allow browser to be used (in headless mode) if Yrewind can't get live stream info on its own. 84 | 85 | ### Changed 86 | 87 | - Now the browser is used only if the `-browser` parameter is specified. 88 | 89 | ### Fixed 90 | 91 | - Error when saving some live streams. 92 | - Several minor fixes. 93 | 94 | ## [22.011] - 2022-01-21 95 | 96 | ### Fixed 97 | 98 | - Several minor fixes. 99 | 100 | ## [21.121] - 2021-12-01 101 | 102 | ### Changed 103 | 104 | - Nothing has changed (technical release). 105 | 106 | ## [21.063] - 2021-06-20 107 | 108 | ### Changed 109 | 110 | - The way to get technical information about live stream - Yrewind now uses a browser (Google Chrome or Microsoft Edge if Chrome is not installed). This method is slightly slower and more buggy, but more resilient to various changes on YouTube servers. 111 | 112 | ## [21.062] - 2021-06-15 113 | 114 | ### Fixed 115 | 116 | - Several minor fixes. 117 | 118 | ## [21.061] - 2021-06-03 119 | 120 | ### Added 121 | 122 | - Parameter `-cookie` to save live stream using cookie file. 123 | 124 | ### Changed 125 | 126 | - The `-start` parameter is now calculated not relative to the time on the computer, but relative to the server time. 127 | - The relative path in the `-ffmpeg` parameter is now determined relative to the location of the batch file (command run) directory, rather than relative to the location of *yrewind.exe*. 128 | 129 | ### Fixed 130 | 131 | - Several minor fixes. 132 | 133 | ### Removed 134 | 135 | - The ability to view a specified time interval using *VLC Media Player*. 136 | 137 | ## [21.051] - 2021-05-20 138 | 139 | ### Added 140 | 141 | - Support for arguments and nested quotes when using the `-executeonexit` parameter. 142 | - The ability to autorestart to get the next part of the stream (command `-executeonexit=*getnext*`). 143 | 144 | ### Fixed 145 | 146 | - Added an additional way to determine UTC time if the page of the required stream contains incorrect information (the reason for the hang of some streams). 147 | - Several minor fixes. 148 | - Updated algorithm for obtaining information about the stream. 149 | 150 | ## [21.041] - 2021-04-06 151 | 152 | ### Changed 153 | 154 | - The format of the metadata in the saved file (it is now formatted as: *title || author || live stream URL || channel URL || UTC start point*). 155 | 156 | ### Fixed 157 | 158 | - Error while saving file if its path contains invalid filesystem characters (when using rename masks `*author*` and `*title*`). 159 | - Several minor fixes. 160 | 161 | ## [21.031] - 2021-03-18 162 | 163 | ### Added 164 | 165 | - Support for saving audio files. 166 | - The `-executeonexit` parameter to run document or executable file after the program finishes. 167 | 168 | ### Fixed 169 | 170 | - Clarified the saved formats. 171 | - Several minor fixes. 172 | 173 | ## [21.023] - 2021-02-28 174 | 175 | ### Fixed 176 | 177 | - Several minor fixes. 178 | 179 | ## [21.022] - 2021-02-24 180 | 181 | ### Added 182 | 183 | - Single-character aliases for all parameters (`-u` for `-url`, `-s` for `-start`, etc.). 184 | 185 | ### Changed 186 | 187 | - The `-pathffmpeg` parameter has been renamed to `-ffmpeg`. The renamed parameter now supports relative paths, now you can also specify the path to *VLC Media Player* (to view the required time interval instead of saving it). 188 | - The `-pathsave` parameter has been renamed to `-output`. The renamed parameter now supports relative paths and rename masks for the output directory (the directory name must now end with a slash). In addition, the functionality of this parameter has been extended with the functionality of the `-filename` and `-vformat` deleted parameters. 189 | 190 | ### Fixed 191 | 192 | - Several minor fixes. 193 | 194 | ### Removed 195 | 196 | - The `-filename` and `-vformat` parameters (their functionality has been moved to the `-output` parameter). 197 | 198 | ## [21.021] - 2021-02-15 199 | 200 | ### Fixed 201 | 202 | - Several minor fixes. 203 | 204 | ## [21.015] - 2021-01-27 205 | 206 | ### Changed 207 | 208 | - Increased the maximum allowed video duration (up to 300 minutes). 209 | 210 | ## [21.014] - 2021-01-17 211 | 212 | ### Fixed 213 | 214 | - Several minor fixes. 215 | 216 | ## [21.013] - 2021-01-12 217 | 218 | ### Added 219 | 220 | - Parameter `-filename` to save live stream with custom file name (rename masks supported). 221 | - Parameter `-start=yyyyMMdd:hhmmss` to specify starting point with seconds. 222 | - Parameter `-url=[channelUrl]` to monitor the specified channel for new live streams. 223 | 224 | ### Fixed 225 | 226 | - The time in the file name now more closely matches the time of the streamer. 227 | - Several minor fixes. 228 | 229 | ## [21.012] - 2021-01-08 230 | 231 | ### Added 232 | 233 | - Option `-start=+[minutes]` for the delayed start of recording. 234 | - Streamer name to video file metadata. 235 | - Time in filename is now with seconds. 236 | 237 | ### Fixed 238 | 239 | - The bug due to which the program could not find information about the stream. 240 | - Several minor fixes. 241 | 242 | ## [21.011] - 2021-01-04 243 | 244 | ### Added 245 | 246 | - Option `-start=beginning` to rewind the live stream to the first available moment. 247 | - Option `-start=wait` to wait for the scheduled live stream to start and then automatically record it from the first second. 248 | 249 | ### Changed 250 | 251 | - The built-in help of the program has become a little more convenient. 252 | 253 | ### Fixed 254 | 255 | - The bug with FFmpeg freezing if stream terminated during real time recording. 256 | - Several minor fixes. 257 | 258 | ### Removed 259 | 260 | - The `-pathchrome` and `-nocache` parameters have been removed. 261 | 262 | ## [20.124] - 2020-12-31 263 | 264 | ### Changed 265 | 266 | - The speed of receiving information about live stream has been increased. 267 | 268 | ### Fixed 269 | 270 | - Several minor fixes. 271 | 272 | ### Removed 273 | 274 | - Dependency on Google Chrome. Now the browser is not required for the program to work. 275 | 276 | ## [20.123] - 2020-12-28 277 | 278 | ### Added 279 | 280 | - Parameter `-vformat=[formatExtension]`. 281 | - Support for resolutions higher than 1080p. 282 | 283 | ### Fixed 284 | 285 | - Several minor fixes. 286 | 287 | ## [20.122] - 2020-12-25 288 | 289 | ### Changed 290 | 291 | - Improved and accelerated work with cache. 292 | - Modes *rewind* and *real time* are combined: now it's possible to save intervals like `-start=-30 -duration=60` (the first part of the file is downloaded at high speed and the rest is recorded in real time). 293 | 294 | ### Fixed 295 | 296 | - The bug due to which all incomplete videos were without sound (for example, when the program was manually closed during recording). 297 | - Several minor fixes. 298 | 299 | ### Removed 300 | 301 | - Sync warning in file name if duration does not match specified. Now program just leaves temp file name. 302 | 303 | ## [20.121] - 2020-12-10 304 | 305 | ### Fixed 306 | 307 | - An issue where some streams could not be downloaded due to an error 9411 (*Cannot process live stream with FFmpeg library*). 308 | - Several minor fixes. 309 | 310 | ## [20.113] - 2020-11-28 311 | 312 | ### Added 313 | 314 | - The *real time* mode: now program can record live stream in real time. 315 | 316 | ### Changed 317 | 318 | - If the `-start` parameter is missing, the program now runs in real time recording mode, saving the *following* 1 hour of the stream, not the previous ones. 319 | - Improved speed of caching information about the required live stream. 320 | - Increased the maximum allowed video duration (up to 90 minutes). 321 | 322 | ### Fixed 323 | 324 | - The bug that caused an exception to be thrown when specifying a non-absolute path to the `-pathsave` parameter. 325 | - Several minor fixes. 326 | 327 | ## [20.112] - 2020-11-16 328 | 329 | ### Added 330 | 331 | - Preliminary internet connection check to prevent FFmpeg freezing. 332 | 333 | ### Changed 334 | 335 | - Increased the maximum allowed video duration (up to 75 minutes). 336 | 337 | ### Fixed 338 | 339 | - The bug with incorrect URL recognition if it was specified without quotes and contained a hyphen. 340 | - Several minor fixes. 341 | 342 | ## [20.111] - 2020-11-03 343 | 344 | ### Added 345 | 346 | - Parameter `-start=-[minutes]`. 347 | 348 | ### Fixed 349 | 350 | - Several minor fixes. 351 | 352 | ## [20.105] - 2020-10-31 353 | 354 | ### Added 355 | 356 | - Duration checking for downloaded videos. 357 | 358 | ### Fixed 359 | 360 | - An error 9124 (*FFmpeg not found*) if *bat* file was located in a different directory than program. 361 | - Several minor fixes. 362 | 363 | ## [20.104] - 2020-10-13 364 | 365 | ### Added 366 | 367 | - Saving metadata to the output video. 368 | 369 | ## [20.103] - 2020-10-12 370 | 371 | ### Changed 372 | 373 | - Command line arguments are now case insensitive. 374 | 375 | ### Fixed 376 | 377 | - Several minor fixes. 378 | 379 | ## [20.102] - 2020-10-07 380 | 381 | ### Fixed 382 | 383 | - Several minor fixes. 384 | 385 | ## [20.101] - 2020-10-06 386 | 387 | ### Added 388 | 389 | - Checking if other instances of the program is running. 390 | - Determining of the earliest available live stream time point. 391 | - The ability to cache live stream information to improve save speed. 392 | 393 | ### Changed 394 | 395 | - Now the program determines the nearest lower resolution if nonexistent is specified (instead of higher). 396 | - The program interface has been redesigned. 397 | 398 | ### Fixed 399 | 400 | - The bug when empty directories created by the current instance of the program (for example, if the video did not downloaded) were not deleted. 401 | - The bug causing the duration of some videos to be several seconds longer than the specified one. 402 | - Several minor fixes. 403 | 404 | ## [20.075] - 2020-07-30 405 | 406 | ### Added 407 | 408 | - Checking for interval availability before downloading it. 409 | 410 | ### Fixed 411 | 412 | - The bug with playing live stream sound when receiving information about it. 413 | - Several minor fixes. 414 | 415 | ## [20.074] - 2020-07-21 416 | 417 | ### Changed 418 | 419 | - To reduce the file size, the assembly of the program has been moved from .NET Core to .NET Framework. 420 | 421 | ### Fixed 422 | 423 | - Several minor fixes. 424 | 425 | ## [20.073] - 2020-07-20 426 | 427 | ### Added 428 | 429 | - Recognition of different URL spellings. 430 | 431 | ### Fixed 432 | 433 | - Several minor fixes. 434 | 435 | ## [20.072] - 2020-07-19 436 | 437 | ### Added 438 | 439 | - The function showing download progress. 440 | - Built-in help (with contents of *readme* file). 441 | - Live stream title parsing. 442 | - Video type recognition (live stream or regular video). 443 | - Video is now first saved under a temporary file name to prevent overwriting in case of an error. 444 | 445 | ### Fixed 446 | 447 | - Several minor fixes. 448 | 449 | ### Removed 450 | 451 | - The ability to download video files without limiting the duration. 452 | 453 | ## [20.071] - 2020-07-06 454 | 455 | ### Added 456 | 457 | - Basic functionality developed. 458 | 459 | [24.061]: https://github.com/rytsikau/ee.Yrewind/releases/download/20240622/ee.yrewind_24.061.zip 460 | [23.081]: https://github.com/rytsikau/ee.Yrewind/releases/download/20230804/ee.yrewind_23.081.zip 461 | [23.071]: https://github.com/rytsikau/ee.Yrewind/releases/download/20230725/ee.yrewind_23.071.zip 462 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ee.Yrewind 2 | 3 | Yrewind is a command line utility for saving YouTube live streams in original quality. 4 | 5 | The program has the following features: 6 | 7 | * Delayed start recording 8 | * Recording in real time 9 | * Downloading recently finished live streams 10 | * Rewinding to the specified time point in the past, and downloading from that point 11 | * Waiting for the scheduled live stream to start and then automatically recording from the first second 12 | * Monitoring the specified channel for new live streams and then automatically recording from the first second 13 | 14 | Supported parameters: 15 | 16 | [ ` -url (-u) ` ](#-urlurl) 17 | [ ` -start (-s) ` ](#-startyyyymmddhhmm--startyyyymmddhhmmss) 18 | [ ` -duration (-d) ` ](#-durationminutes) 19 | [ ` -resolution (-r) ` ](#-resolutionheightpixels)
20 | [ ` -ffmpeg (-f) ` ](#-ffmpegcpathtoffmpeg) 21 | [ ` -output (-o) ` ](#-outputcdir1dir2filenameextension) 22 | [ ` -browser (-b) ` ](#-browsercpathtobrowserfileexe) 23 | [ ` -cookie (-c) ` ](#-cookiecpathtocookiefileext)
24 | [ ` -keepstreaminfo (-k) ` ](#-keepstreaminfofalse) 25 | [ ` -log (-l) ` ](#-logtrue) 26 | [ ` -executeonexit (-e) ` ](#-executeonexitcpathtosomefileext) 27 | 28 |
29 | 30 | # [>> download <<](https://github.com/rytsikau/ee.Yrewind/releases/download/20240622/ee.yrewind_24.061.zip) 31 | Changelog is [here](https://github.com/rytsikau/ee.Yrewind/blob/main/CHANGELOG.md). If something doesn't work, please [report](https://github.com/rytsikau/ee.Yrewind/issues) and try [previous versions](https://github.com/rytsikau/ee.Yrewind/releases). 32 | 33 |
34 | 35 | ## Screenshot 36 | 37 | 38 | 39 |
40 | 41 | ## Quick start 42 | 43 | 1. Unpack the downloaded zip 44 | 2. Open *run.bat* in a text editor and paste the URLs of required streams instead of existing samples 45 | 3. Save *run.bat* and run it 46 | * Some [examples](#examples) 47 | * Since 2022, YouTube throttle third party downloaders. So, for faster operation, use the `-b` parameter pointing to the LEGACY browser. For example, Chrome with version 110 and [below](https://portableapps.com/downloading/?a=GoogleChromePortableLegacyWin7&s=s&p=&d=pa&f=GoogleChromePortableLegacyWin7_109.0.5414.120_online.paf.exe) works well 48 | 49 |
50 | 51 | ## Usage 52 | 53 | The only required command line argument is the `-url`: 54 | 55 | ##### [**` -url=[url] `**](#) 56 | 57 | With this command, the program records a livestream in real time for 1 hour at 1080p resolution, or at a lower if 1080p is not available. URL can be specified in various formats: 58 | > yrewind -url='youtube.com/watch?v=oJUvTVdTMyY' 59 | > yrewind -url=https://www.youtu.be/oJUvTVdTMyY 60 | > yrewind -url=oJUvTVdTMyY 61 | > (etc.) 62 | 63 | Channel URL can also be specified, this allows to wait for a new livestream on the channel and automatically start recording from the first second when it starts. Please note that when specifying a channel URL, active livestreams are ignored, the program will wait for a new one. 64 | > yrewind -url='https://www.youtube.com/c/SkyNews' 65 | > yrewind -url=www.youtube.com/user/SkyNews/ 66 | > yrewind -url='youtube.com/channel/UCoMdktPbSTixAyNGwb-UYkQ' 67 | > yrewind -url=UCoMdktPbSTixAyNGwb-UYkQ 68 | 69 |
70 | 71 | To rewind the livestream or delay the start of recording, use the `-start` parameter. It has several spellings: 72 | 73 | ##### [**` -start=[YYYYMMDD:hhmm], -start=[YYYYMMDD:hhmmss] `**](#) 74 | ##### [**` -start=[Y:hhmm], -start=[Y:hhmmss] `**](#) 75 | ##### [**` -start=[T:hhmm], -start=[T:hhmmss] `**](#) 76 | ##### [**` -start=beginning, -start=b `**](#) 77 | ##### [**` -start=-[minutes] `**](#) 78 | ##### [**` -start=+[minutes] `**](#) 79 | 80 | This parameter specifies the point in time from which to save the stream. It's calculated relative to the moment the program was started (displayed in the first line). If the parameter is missing, program saves ongoing livestream in real time, scheduled and finished - from the beginning. Depending on technical parameters of the livestream, start point may be shifted from the requested one by several seconds (in a larger direction). 81 | 82 | To download the time interval from 7:10AM to 8:10AM on July 15, 2020: 83 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -start=20200715:0710 84 | 85 | To download the time interval from yesterday 10:15PM to 11:15PM: 86 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -start=Y:2215 87 | 88 | To download the time interval from today 02:00AM to 03:00AM: 89 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -start=T:020000 90 | 91 | To download from the first currently available moment: 92 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -start=beginning 93 | 94 | To download the time interval from 3 hours ago to 2 hours ago: 95 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -start=-180 96 | 97 | To wait 2 hours, then record for 1 hour: 98 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -start=+120 99 | 100 |
101 | 102 | The program also has several other parameters: 103 | 104 | ##### [**` -duration=[minutes] `**](#) 105 | ##### [**` -duration=[minutes].[seconds] `**](#) 106 | 107 | Specifies the required duration. The minimum value is 0.01 (1 second), the maximum is limited to 300 (5 hours). If the parameter is missing, program uses the default 1 hour. Depending on technical parameters of the livestream, result duration may differ from the requested one by several seconds (in a larger direction). The examples below saves 300 minutes of the livestream: 108 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -duration=300 109 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -duration=300.00 110 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -duration=max 111 | 112 |
113 | 114 | ##### [**` -resolution=[heightPixels] `**](#) 115 | 116 | Specifies the required resolution in pixels (height). If this parameter is missing, program uses the default 1080. If the requested resolution is not available, the nearest lower will be selected. In the examples below, the livestream will be saved at 144p: 117 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -resolution=144 118 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -resolution=200 119 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -resolution=1 120 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -resolution=min 121 | 122 |
123 | 124 | ##### [**` -ffmpeg='c:\path\to\ffmpeg\' `**](#) 125 | 126 | Specifies the path to FFmpeg library. If relative path is specified, the base folder is the folder from which the command line was run. If this parameter is missing, Yrewind tries to find FFmpeg in its own folder and using environment variables. 127 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -ffmpeg='c:\Program Files\FFmpeg\' 128 | 129 |
130 | 131 | ##### [**` -output='c:\dir1\dir2\filename.extension' `**](#) 132 | 133 | Specifies custom folder, filename and extension (media container format) for the saved livestream. If relative path is specified, the base folder is the folder from which the command line was run. If this parameter is missing, program uses the next default values: 134 | * `[batch file folder]\saved_streams\` - for folder 135 | * `[id]_[date]-[time]_[duration]_[resolution]` - for filename 136 | * `.mkv` - for extension 137 | 138 | The `-output` parameter can be specified partially, then the missing parts are replaced with default values. In this case, the part of the string to the right of the last slash is interpreted as filename and/or extension. If the string does not contain slashes, it's fully interpreted as filename and/or extension: 139 | * `c:\dir1\dir2\` - custom folder, default filename, default extension 140 | * `dir1\filename` - custom subfolder, custom filename, default extension 141 | * `dir1\.extension` - custom subfolder, default filename, custom extension 142 | * `filename` - default folder, custom filename, default extension 143 | * `.extension` - default folder, default filename, custom extension 144 | 145 | Folder and filename supports renaming masks: `*id*`, `*start*`, `*start[customDateTime]*` (recognizes letters yyyyMMddHHmmss), `*duration*`, `*resolution*`, `*channel_id*`, `*author*` and `*title*`. 146 | 147 | The extension defines the format of the media container in which the livestream will be saved. Formats description: 148 | * `.avi`, `.mp4` - use AVC and MP4a data; if AVC is unavailable, use VP9 149 | * `.asf`, `.mkv`, `.wmv` - use VP9 and MP4a data; if VP9 is unavailable, use AVC 150 | * `.3gp`, ` .flv`, ` .mov`, `.ts` - use AVC and MP4a data; doesn't support high resolutions - saves at 1080p even if requested higher resolution is available 151 | * `.aac`, `.m4a`, `.wma` - use MP4a data; saves only audio (to save only audio, you can also specify zero resolution `-r=0` - this works with all audio and video formats except `.mp4`) 152 | * `.m3u`, `.m3u8` - playlist files pointing to livestream on the Internet, allows watching in media player without downloading. Tested with VLC. Shelf life of playlist files is only 6 hours 153 | 154 | The example below saves the livestream as *\saved_streams\oJUvTVdTMyY_20210123-185830_060.00m_1080p.ts*: 155 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -output=.ts 156 | 157 | The next example saves the livestream as *d:\Streams\Sky News\2021-01-12_12-34.mkv*: 158 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -output='d:\Streams\*author*\*start[yyyy-MM-dd_HH-mm]*' 159 | 160 |
161 | 162 | ##### [**` -browser='c:\path\to\browser\file.exe' `**](#) 163 | 164 | Allows to use an alternative browser to get technical information about the livestream. For the portable version of browser, specify the full path to the executable file; for the installed version, it's usually enough to specify the name. Only Chromium-based browsers are supported - Chrome, Edge, Brave, Opera, Vivaldi, etc. If this parameter is missing, program uses pre-installed MS Edge. If this parameter is set to `false`, Yrewind will not use browser, but this may slow down the download speed. 165 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -browser='d:\Portable programs\Vivaldi\Application\vivaldi.exe' 166 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -browser=chrome 167 | 168 |
169 | 170 | ##### [**` -cookie='c:\path\to\cookie\file.ext' `**](#) 171 | 172 | Specifies the path to the cookie file. If relative path is specified, the base folder is the folder from which the command line was run. The parameter can be useful if YouTube requires a captcha or authorization to access a livestream with age or membership restrictions. The cookie file must be in Netscape format and can be obtained using any suitable browser add-on. Please note that cookie created after solving captcha is usable for only a few hours. Instead of solving captcha, it's better to log in to YouTube and create cookie after that. 173 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -cookie='cookies.txt' 174 | 175 |
176 | 177 | ##### [**` -keepstreaminfo=false `**](#) 178 | 179 | If this parameter is set to `false`, Yrewind will not keep technical information about the livestream in a temporary cache file, and will delete this file if it exists. 180 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -keepstreaminfo=false 181 | 182 |
183 | 184 | ##### [**` -log=true `**](#) 185 | 186 | If this parameter is set to `true`, Yrewind will generate log files (in the folder from which the command line was run). 187 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -log=true 188 | 189 |
190 | 191 | ##### [**` -executeonexit='c:\path\to\some\file.ext' `**](#) 192 | 193 | Specifies the command to run after Yrewind exits. If it's an executable file, you can also specify the arguments it supports (don't forget the quotes - nested supported). The non-executable file will be launched by the associated program. The parameter supports two rename masks - `*output*`, which contains the full path of the saved video, and `*getnext*`, which contains the command to start Yrewind again to get the next interval of the stream. When using `-executeonexit=*getnext*` command inside a batch file, keep in mind that this file is first executed to the end, and only then the `*getnext*` command is executed. Also use rename masks `*start*` and `*start[customDateTime]*` to avoid duplicate names of saved stream parts (or just use default auto-naming). In the first example below, the saved video will be opened by the associated program, in the second - using the specified program: 194 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -executeonexit=*output* 195 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -executeonexit=''c:\Program Files\VLC mediaplayer\vlc.exe' *output*' 196 | 197 |
198 | 199 | ## Examples 200 | 201 | Save 1 hour of the stream from 04:55AM to 05:55AM on May 5, 2020, at 720p, to specified folder: 202 | > yrewind -u=oJUvTVdTMyY -s=20200505:0455 -r=720 -o='d:\Streams\' 203 | 204 | Save 89 minutes 30 seconds of the stream from today 10:45AM to 12:15PM, at 1080p: 205 | > yrewind -u=oJUvTVdTMyY -s=T:1045 -d=89.30 206 | 207 | Record livestream until it ends, starting from the beginning, in `.ts` format, save result video to desktop: 208 | > yrewind -u=oJUvTVdTMyY -s=b -o=%UserProfile%\Desktop\.ts -e=*getnext* 209 | 210 | Immediately play (without downloading) with assotiated mediaplayer, from yesterday 03:00AM, at the maximum available resolution: 211 | > yrewind -u=oJUvTVdTMyY -s=Y:0300 -r=max -o=.m3u -e=*output* 212 | 213 | Wait for a new livestream on the specified channel, then start recording from the 30th minute: 214 | > yrewind -u=https://www.youtube.com/c/SkyNews -s=+30 215 | 216 | Batch file example (runs 3 copies of the program at the same time): 217 | ``` 218 | @echo off 219 | set O='d:\Streams\' 220 | set B='c:\Program Files\Chrome\chrome.exe' 221 | set F='c:\Program Files\FFmpeg\' 222 | set Y='c:\Program Files\ee.Yrewind\' 223 | cd /d %Y% 224 | 225 | set U=oJUvTVdTMyY 226 | start yrewind -f=%F% -b=%B% -o=%O% -u=%U% -s=Y:1500 227 | start yrewind -f=%F% -b=%B% -o=%O% -u=%U% -s=Y:1600 228 | start yrewind -f=%F% -b=%B% -o=%O% -u=%U% -s=Y:1700 229 | ``` 230 | 231 |
232 | 233 | ## Notes 234 | 235 | * Loss of packets on the streamer side causes the estimated time to shift. The offset is usually seconds, but if its internet connection is unstable and/or the stream has been running for a long time, it can be minutes or even hours. For example, if the stream was interrupted for a total of 1 hour, then 24-hour frames will be presented as 23-hour. Thus, start point time accuracy can only be guaranteed for the current moment. The further the livestream is rewound, the less accuracy. Also, if there are interruptions in the livestream at the specified time interval, the duration of the saved file will be shorter by the total duration of those interruptions; a warning for this incompleted file will be displayed 236 | * Occasionally, the message `unable to verify the saved file is correct` appears. The reasons may be as follows: if the duration of the saved file cannot be verified (there is a possibility that the file is damaged); if the duration of the saved file does not match the requested one (also in this case, the output file name contains the word *INCOMPLETE*); if the starting point of the requested time interval cannot be accurately determined (for example due to server side error) 237 | * To reduce the chance of output file corruption, it's better not to use Moov Atom formats (`.3gp`, `.mov`, `.mp4`, `.m4a`) for long recordings. Also, these formats don't allow to play a file that is in the process of downloading (other formats do) 238 | * Recently finished livestream can be downloaded within approximately 6 hours of its completion. After this time, such stream 'turns' into a regular video and can be downloaded, for example, using Youtube-dl 239 | * When using proxy, VPN or special firewall settings, keep in mind that not only Yrewind should have appropriate access, but also FFmpeg 240 | 241 |
242 | 243 | ## Terms of use 244 | 245 | * This software provides access to information on the Internet that is publicly available and does not require authorization or authentication 246 | * This software is free for non-commercial use and is provided 'as is' without warranty of any kind, either express or implied 247 | * The author will not be liable for data loss, damages or any other kind of loss while using or misusing this software 248 | * The author will not be liable for the misuse of content obtained using this software, including copyrighted, age-restricted, or any other protected content 249 | 250 |
251 | 252 | ## Developer info 253 | 254 | * C# 255 | * .NET Framework 4.8 256 | * Visual Studio Community 2022 257 | 258 |
259 | 260 | ## Requirements 261 | 262 | * FFmpeg static build (included in the archive) 263 | * Windows 7 and on / Windows Server 2008 R2 and on 264 | * Chromium-based browser 265 | 266 |
267 | 268 | ## Tested configuration 269 | 270 | * FFmpeg 4.3 x86 (by Zeranoe) 271 | * Windows 10 Pro x32 272 | * Windows 10 Pro x64 273 | 274 |
275 | 276 | ## About 277 | 278 | Console utility for saving YouTube live streams with rewind function up to 167 hours 279 | 280 |
281 | 282 | ## Tags 283 | 284 | download downloader dvr live livestream record rewind save stream youtube 285 | 286 | --- 287 | [[program page]](https://rytsikau.github.io/ee.Yrewind) [[start page]](https://rytsikau.github.io) [[author e-mail]](mailto:y.rytsikau@gmail.com) 288 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-leap-day -------------------------------------------------------------------------------- /_updated.log: -------------------------------------------------------------------------------- 1 | 240622;https://github.com/rytsikau/ee.Yrewind/releases/download/20240622/ee.yrewind_24.061.zip 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rytsikau/ee.Yrewind/d9e4f994fb345cd5c28005f8a89d316ebf92b6bb/screenshot.png -------------------------------------------------------------------------------- /source/Cache.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.Linq; 6 | 7 | namespace yrewind 8 | { 9 | // Caching the technical information about the stream in a temporary file 10 | // Line format for each live stream: 11 | // [dateTimeOfGettingInfo] [id] [idStatus] [channelId] [uriAdirect] [uriVdirect] [jsonHtmlStr] 12 | class Cache 13 | { 14 | #region Read - Read required data from cache 15 | public void Read( 16 | string id, 17 | out string idStatus, 18 | out string channelId, 19 | out string uriAdirect, 20 | out string uriVdirect, 21 | out string jsonHtmlStr 22 | ) 23 | { 24 | idStatus = string.Empty; 25 | channelId = string.Empty; 26 | uriAdirect = string.Empty; 27 | uriVdirect = string.Empty; 28 | jsonHtmlStr = string.Empty; 29 | 30 | foreach (var line in GetContent()) 31 | { 32 | if (line == string.Empty) continue; 33 | 34 | var lineId = line.Split('\t')[1]; 35 | 36 | if (id.Trim() == lineId.Trim()) 37 | { 38 | idStatus = line.Split('\t')[2]; 39 | channelId = line.Split('\t')[3]; 40 | uriAdirect = line.Split('\t')[4]; 41 | uriVdirect = line.Split('\t')[5]; 42 | jsonHtmlStr = line.Split('\t')[6]; 43 | } 44 | } 45 | } 46 | #endregion 47 | 48 | #region Write - Write cache file 49 | public void Write( 50 | string id, 51 | string idStatus, 52 | string channelId, 53 | string uriAdirect, 54 | string uriVdirect, 55 | string jsonHtmlStr 56 | ) 57 | { 58 | var content = GetContent(); 59 | var newData = 60 | (Program.Start + Program.Timer.Elapsed).ToString("yyyyMMdd-HHmmss") + '\t' + 61 | id + '\t' + 62 | idStatus + '\t' + 63 | channelId + '\t' + 64 | uriAdirect + '\t' + 65 | uriVdirect + '\t' + 66 | jsonHtmlStr; 67 | 68 | var match = content.FirstOrDefault(x => x.Contains("\t" + id + "\t")); 69 | if (match == null) 70 | { 71 | content.Add(newData); 72 | } 73 | else if ((match.Split('\t')[4] == string.Empty) & (uriAdirect != string.Empty)) 74 | { 75 | // Prefer data containing direct browser URLs 76 | content.Remove(match); 77 | content.Add(newData); 78 | } 79 | 80 | if (Validator.Log) 81 | { 82 | Program.Log(string.Join(Environment.NewLine, content), "cache"); 83 | } 84 | 85 | try 86 | { 87 | File.WriteAllLines(Constants.PathCache, content); 88 | } 89 | catch (Exception e) 90 | { 91 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 92 | if (Validator.Log) Program.Log(Program.ErrInfo); 93 | } 94 | } 95 | #endregion 96 | 97 | #region Delete - Delete cache file 98 | public void Delete() 99 | { 100 | try 101 | { 102 | File.Delete(Constants.PathCache); 103 | } 104 | catch (Exception e) 105 | { 106 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 107 | if (Validator.Log) Program.Log(Program.ErrInfo); 108 | } 109 | } 110 | #endregion 111 | 112 | #region GetContent - Read unexpired data from cache file 113 | List GetContent() 114 | { 115 | DateTime dtAdded; 116 | var content = new List(); 117 | 118 | try 119 | { 120 | if (new FileInfo(Constants.PathCache).Length > 1000000) throw new Exception(); 121 | 122 | foreach (var line in File.ReadAllLines(Constants.PathCache)) 123 | { 124 | if (line == string.Empty) continue; 125 | 126 | dtAdded = DateTime.ParseExact(line.Split('\t')[0], "yyyyMMdd-HHmmss", null); 127 | var dtExpiration = dtAdded.AddMinutes(Constants.CacheShelflifeMinutes); 128 | if (Program.Start + Program.Timer.Elapsed > dtExpiration) continue; 129 | 130 | content.Add(line); 131 | } 132 | } 133 | catch (Exception e) 134 | { 135 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 136 | if (Validator.Log) Program.Log(Program.ErrInfo); 137 | } 138 | 139 | return content; 140 | } 141 | #endregion 142 | } 143 | } -------------------------------------------------------------------------------- /source/Constants.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Reflection; 5 | 6 | namespace yrewind 7 | { 8 | // Storing messages, readme text and various constants 9 | static class Constants 10 | { 11 | #region Msg - Messages 12 | public static readonly Dictionary Msg = new Dictionary 13 | { 14 | [9000] = "[class Program]", 15 | [9050] = "----------------------------------------------------------------", 16 | [9051] = "{0} (version {1})", 17 | [9052] = "--> {0} (started at {1})", 18 | [9053] = "--> OK ({0})", 19 | [9054] = "--> ERROR {0}: {1} ({2})", 20 | [9055] = "", 21 | [9056] = "", 22 | [9057] = "Check '-output' argument", 23 | [9058] = "Checking", 24 | [9059] = "Command line input is too long", 25 | [9060] = "Current version is actual", 26 | [9061] = "Delayed start is limited to 24 hours", 27 | [9062] = "Downloading, please wait a minute", 28 | [9063] = "Error", 29 | [9064] = "File already exists", 30 | [9065] = "Getting live stream info", 31 | [9066] = "New version available", 32 | [9067] = "Output file", 33 | [9068] = "Please see the actual text on the program page", 34 | [9069] = "Press to download", 35 | [9070] = " exit", 36 | [9071] = " show help", 37 | [9072] = " check for updates", 38 | [9073] = "Ready", 39 | [9074] = "Waiting", 40 | [9075] = "YouTube not responding", 41 | [9076] = "Resolutions", 42 | [9077] = "Saving", 43 | [9078] = "See file '{0}' on the desktop", 44 | [9079] = "Skipped! File already exists", 45 | [9080] = "Stream title", 46 | [9081] = "Unable to check for updates", 47 | [9082] = "Unable to verify the saved file is correct", 48 | [9083] = "Use , , , to scroll", 49 | 50 | [9100] = "[class Validator]", 51 | [9110] = "Check for duplicate arguments on command line input", 52 | [9111] = "Cannot read cookie file", 53 | [9112] = "Check command line input", 54 | [9113] = "Required argument '-url' not found", 55 | [9114] = "Check '-url' argument", 56 | [9115] = "Check '-start' argument", 57 | [9116] = "Check '-duration' argument", 58 | [9117] = "Check '-resolution' argument", 59 | [9118] = "Check '-ffmpeg' argument", 60 | [9119] = "Check '-output' argument", 61 | [9120] = "Check '-browser' argument", 62 | [9121] = "Check '-cookie' argument", 63 | 64 | [9200] = "[class Waiter]", 65 | [9210] = "Cannot get live stream information", 66 | [9211] = "This live stream ended too long ago, now it's a regular video", 67 | [9212] = "Cannot get channel information", 68 | [9213] = "Cannot get channel information. If URL contains '%', is it escaped?", 69 | [9214] = "Video unavailable", 70 | [9215] = "It's not a live stream", 71 | [9216] = "Saving copyrighted live streams is blocked", 72 | [9217] = "Seems to be a restricted live stream, try '-b' or '-c' option", 73 | [9218] = "For an upcoming stream, start point cannot be in the past", 74 | [9219] = "Cannot get live stream direct URL", 75 | [9220] = "Cannot get live stream information with browser", 76 | 77 | [9300] = "[class Preparer]", 78 | [9310] = "Cannot get live stream information", 79 | [9311] = "Cannot get live stream information with browser", 80 | [9312] = "Further parts of the stream are unavailable", 81 | [9313] = "Requested time interval isn't available", 82 | 83 | [9400] = "[class Saver]", 84 | [9410] = "FFmpeg not responding", 85 | [9411] = "Output file(s) creating error", 86 | [9412] = "Output folder creating error", 87 | [9413] = "Output file isn't completed, there will be a retry", 88 | 89 | [9500] = "[class Cache]", 90 | 91 | [9900] = "", 92 | [9999] = "Unknown error" 93 | }; 94 | #endregion 95 | 96 | #region Itag - Description of formats ('itag' codes) 97 | // [itag] = "container;content;resolution/bitrate;other(3d,hdr,vr,etc.)" 98 | public static readonly Dictionary Itag = new Dictionary 99 | { 100 | [5] = "flv;audio+video;240p30;-", 101 | [6] = "flv;audio+video;270p30;-", 102 | [17] = "3gp;audio+video;144p30;-", 103 | [18] = "mp4;audio+video;360p30;-", 104 | [22] = "mp4;audio+video;720p30;-", 105 | [34] = "flv;audio+video;360p30;-", 106 | [35] = "flv;audio+video;480p30;-", 107 | [36] = "3gp;audio+video;180p30;-", 108 | [37] = "mp4;audio+video;1080p30;-", 109 | [38] = "mp4;audio+video;3072p30;-", 110 | [43] = "webm;audio+video;360p30;-", 111 | [44] = "webm;audio+video;480p30;-", 112 | [45] = "webm;audio+video;720p30;-", 113 | [46] = "webm;audio+video;1080p30;-", 114 | [82] = "mp4;audio+video;360p30;3d", 115 | [83] = "mp4;audio+video;480p30;3d", 116 | [84] = "mp4;audio+video;720p30;3d", 117 | [85] = "mp4;audio+video;1080p30;3d", 118 | [92] = "hls;audio+video;240p30;3d", 119 | [93] = "hls;audio+video;360p30;3d", 120 | [94] = "hls;audio+video;480p30;3d", 121 | [95] = "hls;audio+video;720p30;3d", 122 | [96] = "hls;audio+video;1080p30;-", 123 | [100] = "webm;audio+video;360p30;3d", 124 | [101] = "webm;audio+video;480p30;3d", 125 | [102] = "webm;audio+video;720p30;3d", 126 | [132] = "hls;audio+video;240p30;-", 127 | [133] = "mp4;video;240p30;-", 128 | [134] = "mp4;video;360p30;-", 129 | [135] = "mp4;video;480p30;-", 130 | [136] = "mp4;video;720p30;-", 131 | [137] = "mp4;video;1080p30;-", 132 | [138] = "mp4;video;2160p60;-", 133 | [139] = "m4a;audio;48k;-", 134 | [140] = "m4a;audio;128k;-", 135 | [141] = "m4a;audio;256k;-", 136 | [151] = "hls;audio+video;72p30;-", 137 | [160] = "mp4;video;144p30;-", 138 | [167] = "webm;video;360p30;-", 139 | [168] = "webm;video;480p30;-", 140 | [169] = "webm;video;1080p30;-", 141 | [171] = "webm;audio;128k;-", 142 | [218] = "webm;video;480p30;-", 143 | [219] = "webm;video;144p30;-", 144 | [242] = "webm;video;240p30;-", 145 | [243] = "webm;video;360p30;-", 146 | [244] = "webm;video;480p30;-", 147 | [245] = "webm;video;480p30;-", 148 | [246] = "webm;video;480p30;-", 149 | [247] = "webm;video;720p30;-", 150 | [248] = "webm;video;1080p30;-", 151 | [249] = "webm;audio;50k;-", 152 | [250] = "webm;audio;70k;-", 153 | [251] = "webm;audio;160k;-", 154 | [264] = "mp4;video;1440p30;-", 155 | [266] = "mp4;video;2160p60;-", 156 | [271] = "webm;video;1440p30;-", 157 | [272] = "webm;video;2880p30;-", 158 | [278] = "webm;video;144p30;-", 159 | [298] = "mp4;video;720p60;-", 160 | [299] = "mp4;video;1080p60;-", 161 | [302] = "webm;video;720p60;-", 162 | [303] = "webm;video;1080p60;-", 163 | [308] = "webm;video;1440p60;-", 164 | [313] = "webm;video;2160p30;-", 165 | [315] = "webm;video;2160p60;-", 166 | [330] = "webm;video;144p60;hdr", 167 | [331] = "webm;video;240p60;hdr", 168 | [332] = "webm;video;360p60;hdr", 169 | [333] = "webm;video;480p60;hdr", 170 | [334] = "webm;video;720p60;hdr", 171 | [335] = "webm;video;1080p60;hdr", 172 | [336] = "webm;video;1440p60;hdr", 173 | [337] = "webm;video;2160p60;hdr", 174 | [394] = "mp4;video;144p30;-", 175 | [395] = "mp4;video;240p30;-", 176 | [396] = "mp4;video;360p30;-", 177 | [397] = "mp4;video;480p30;-", 178 | [398] = "mp4;video;720p30;-", 179 | [399] = "mp4;video;1080p30;-", 180 | [400] = "mp4;video;1440p30;-", 181 | [401] = "mp4;video;2160p30;-", 182 | [402] = "mp4;video;2880p30;-", 183 | }; 184 | #endregion 185 | 186 | #region Help - Program readme text 187 | public static readonly string Help = @" 188 | # ee.Yrewind 189 | 190 | Yrewind is a command line utility for saving YouTube live streams in original quality. 191 | 192 | The program has the following features: 193 | 194 | * Delayed start recording 195 | * Recording in real time 196 | * Downloading recently finished live streams 197 | * Rewinding to the specified time point in the past, and downloading from that point 198 | * Waiting for the scheduled live stream to start and then automatically recording from the first second 199 | * Monitoring the specified channel for new live streams and then automatically recording from the first second 200 | 201 | 202 | 203 | # Supported parameters 204 | 205 | -url (-u) 206 | -start (-s) 207 | -duration (-d) 208 | -resolution (-r) 209 | -ffmpeg (-f) 210 | -output (-o) 211 | -browser (-b) 212 | -cookie (-c) 213 | -keepstreaminfo (-k) 214 | -log (-l) 215 | -executeonexit (-e) 216 | 217 | 218 | 219 | # Links 220 | 221 | Download: https://github.com/rytsikau/ee.Yrewind/releases/download/20240622/ee.yrewind_24.061.zip 222 | Changelog: https://github.com/rytsikau/ee.Yrewind/blob/main/CHANGELOG.md 223 | Report an issue: https://github.com/rytsikau/ee.Yrewind/issues 224 | Previous versions: https://github.com/rytsikau/ee.Yrewind/releases 225 | Program screenshot: https://github.com/rytsikau/ee.yrewind/raw/main/screenshot.png 226 | 227 | 228 | 229 | # Quick start 230 | 231 | 1. Unpack the downloaded zip 232 | 2. Open *run.bat* in a text editor and paste the URLs of required streams instead of existing samples 233 | 3. Save *run.bat* and run it 234 | * Some [examples](#examples) 235 | 236 | 237 | 238 | # Usage 239 | 240 | The only required command line argument is the `-url`: 241 | 242 | -url=[url] 243 | 244 | With this command, the program records a livestream in real time for 1 hour at 1080p resolution, or at a lower if 1080p is not available. URL can be specified in various formats: 245 | > yrewind -url='youtube.com/watch?v=oJUvTVdTMyY' 246 | > yrewind -url=https://www.youtu.be/oJUvTVdTMyY 247 | > yrewind -url=oJUvTVdTMyY 248 | > (etc.) 249 | 250 | Channel URL can also be specified, this allows to wait for a new livestream on the channel and automatically start recording from the first second when it starts. Please note that when specifying a channel URL, active livestreams are ignored, the program will wait for a new one. 251 | > yrewind -url='https://www.youtube.com/c/SkyNews' 252 | > yrewind -url=www.youtube.com/user/SkyNews/ 253 | > yrewind -url='youtube.com/channel/UCoMdktPbSTixAyNGwb-UYkQ' 254 | > yrewind -url=UCoMdktPbSTixAyNGwb-UYkQ 255 | 256 | 257 | 258 | To rewind the livestream or delay the start of recording, use the `-start` parameter. It has several spellings: 259 | 260 | -start=[YYYYMMDD:hhmm], -start=[YYYYMMDD:hhmmss] 261 | -start=[Y:hhmm], -start=[Y:hhmmss] 262 | -start=[T:hhmm], -start=[T:hhmmss] 263 | -start=beginning, -start=b 264 | -start=-[minutes] 265 | -start=+[minutes] 266 | 267 | This parameter specifies the point in time from which to save the stream. It's calculated relative to the moment the program was started (displayed in the first line). If the parameter is missing, program saves ongoing livestream in real time, scheduled and finished - from the beginning. Depending on technical parameters of the livestream, start point may be shifted from the requested one by several seconds (in a larger direction). 268 | 269 | To download the time interval from 7:10AM to 8:10AM on July 15, 2020: 270 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -start=20200715:0710 271 | 272 | To download the time interval from yesterday 10:15PM to 11:15PM: 273 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -start=Y:2215 274 | 275 | To download the time interval from today 02:00AM to 03:00AM: 276 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -start=T:020000 277 | 278 | To download from the first currently available moment: 279 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -start=beginning 280 | 281 | To download the time interval from 3 hours ago to 2 hours ago: 282 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -start=-180 283 | 284 | To wait 2 hours, then record for 1 hour: 285 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -start=+120 286 | 287 | 288 | 289 | The program also has several other parameters: 290 | 291 | -duration=[minutes] 292 | -duration=[minutes].[seconds] 293 | 294 | Specifies the required duration. The minimum value is 0.01 (1 second), the maximum is limited to 300 (5 hours). If the parameter is missing, program uses the default 1 hour. Depending on technical parameters of the livestream, result duration may differ from the requested one by several seconds (in a larger direction). The examples below saves 300 minutes of the livestream: 295 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -duration=300 296 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -duration=300.00 297 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -duration=max 298 | 299 | 300 | 301 | -resolution=[heightPixels] 302 | 303 | Specifies the required resolution in pixels (height). If this parameter is missing, program uses the default 1080. If the requested resolution is not available, the nearest lower will be selected. In the examples below, the livestream will be saved at 144p: 304 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -resolution=144 305 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -resolution=200 306 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -resolution=1 307 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -resolution=min 308 | 309 | 310 | 311 | -ffmpeg='c:\path\to\ffmpeg\' 312 | 313 | Specifies the path to FFmpeg library. If relative path is specified, the base folder is the folder from which the command line was run. If this parameter is missing, Yrewind tries to find FFmpeg in its own folder and using environment variables. 314 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -ffmpeg='c:\Program Files\FFmpeg\' 315 | 316 | 317 | 318 | -output='c:\dir1\dir2\filename.extension' 319 | 320 | Specifies custom folder, filename and extension (media container format) for the saved livestream. If relative path is specified, the base folder is the folder from which the command line was run. If this parameter is missing, program uses the next default values: 321 | * `[batch file folder]\saved_streams\` - for folder 322 | * `[id]_[date]-[time]_[duration]_[resolution]` - for filename 323 | * `.mkv` - for extension 324 | 325 | The `-output` parameter can be specified partially, then the missing parts are replaced with default values. In this case, the part of the string to the right of the last slash is interpreted as filename and/or extension. If the string does not contain slashes, it's fully interpreted as filename and/or extension: 326 | * `c:\dir1\dir2\` - custom folder, default filename, default extension 327 | * `dir1\filename` - custom subfolder, custom filename, default extension 328 | * `dir1\.extension` - custom subfolder, default filename, custom extension 329 | * `filename` - default folder, custom filename, default extension 330 | * `.extension` - default folder, default filename, custom extension 331 | 332 | Folder and filename supports renaming masks: `*id*`, `*start*`, `*start[customDateTime]*` (recognizes letters yyyyMMddHHmmss), `*duration*`, `*resolution*`, `*channel_id*`, `*author*` and `*title*`. 333 | 334 | The extension defines the format of the media container in which the livestream will be saved. Formats description: 335 | * `.avi`, `.mp4` - use AVC and MP4a data; if AVC is unavailable, use VP9 336 | * `.asf`, `.mkv`, `.wmv` - use VP9 and MP4a data; if VP9 is unavailable, use AVC 337 | * `.3gp`, ` .flv`, ` .mov`, `.ts` - use AVC and MP4a data; doesn't support high resolutions - saves at 1080p even if requested higher resolution is available 338 | * `.aac`, `.m4a`, `.wma` - use MP4a data; saves only audio (to save only audio, you can also specify zero resolution `-r=0` - this works with all audio and video formats except `.mp4`) 339 | * `.m3u`, `.m3u8` - playlist files pointing to livestream on the Internet, allows watching in media player without downloading. Tested with VLC. Shelf life of playlist files is only 6 hours 340 | 341 | The example below saves the livestream as *\saved_streams\oJUvTVdTMyY_20210123-185830_060.00m_1080p.ts*: 342 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -output=.ts 343 | 344 | The next example saves the livestream as *d:\Streams\Sky News\2021-01-12_12-34.mkv*: 345 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -output='d:\Streams\*author*\*start[yyyy-MM-dd_HH-mm]*' 346 | 347 | 348 | 349 | -browser='c:\path\to\browser\file.exe' 350 | 351 | Allows to use an alternative browser to get technical information about the livestream. For the portable version of browser, specify the full path to the executable file; for the installed version, it's usually enough to specify the name. Only Chromium-based browsers are supported - Chrome, Edge, Brave, Opera, Vivaldi, etc. If this parameter is missing, program uses pre-installed MS Edge. If this parameter is set to `false`, Yrewind will not use browser, but this may slow down the download speed. 352 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -browser='d:\Portable programs\Vivaldi\Application\vivaldi.exe' 353 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -browser=chrome 354 | 355 | 356 | 357 | -cookie='c:\path\to\cookie\file.ext' 358 | 359 | Specifies the path to the cookie file. If relative path is specified, the base folder is the folder from which the command line was run. The parameter can be useful if YouTube requires a captcha or authorization to access a livestream with age or membership restrictions. The cookie file must be in Netscape format and can be obtained using any suitable browser add-on. Please note that cookie created after solving captcha is usable for only a few hours. Instead of solving captcha, it's better to log in to YouTube and create cookie after that. 360 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -cookie='cookies.txt' 361 | 362 | 363 | 364 | -keepstreaminfo=false 365 | 366 | If this parameter is set to `false`, Yrewind will not keep technical information about the livestream in a temporary cache file, and will delete this file if it exists. 367 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -keepstreaminfo=false 368 | 369 | 370 | 371 | -log=true 372 | 373 | If this parameter is set to `true`, Yrewind will generate log files (in the folder from which the command line was run). 374 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -log=true 375 | 376 | 377 | 378 | -executeonexit='c:\path\to\some\file.ext' 379 | 380 | Specifies the command to run after Yrewind exits. If it's an executable file, you can also specify the arguments it supports (don't forget the quotes - nested supported). The non-executable file will be launched by the associated program. The parameter supports two rename masks - `*output*`, which contains the full path of the saved video, and `*getnext*`, which contains the command to start Yrewind again to get the next interval of the stream. When using `-executeonexit=*getnext*` command inside a batch file, keep in mind that this file is first executed to the end, and only then the `*getnext*` command is executed. Also use rename masks `*start*` and `*start[customDateTime]*` to avoid duplicate names of saved stream parts (or just use default auto-naming). In the first example below, the saved video will be opened by the associated program, in the second - using the specified program: 381 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -executeonexit=*output* 382 | > yrewind -url='https://www.youtube.com/watch?v=oJUvTVdTMyY' -executeonexit=''c:\Program Files\VLC mediaplayer\vlc.exe' *output*' 383 | 384 | 385 | 386 | # Examples 387 | 388 | Save 1 hour of the stream from 04:55AM to 05:55AM on May 5, 2020, at 720p, to specified folder: 389 | > yrewind -u=oJUvTVdTMyY -s=20200505:0455 -r=720 -o='d:\Streams\' 390 | 391 | Save 89 minutes 30 seconds of the stream from today 10:45AM to 12:15PM, at 1080p: 392 | > yrewind -u=oJUvTVdTMyY -s=T:1045 -d=89.30 393 | 394 | Record livestream until it ends, starting from the beginning, in `.ts` format, save result video to desktop: 395 | > yrewind -u=oJUvTVdTMyY -s=b -o=%UserProfile%\Desktop\.ts -e=*getnext* 396 | 397 | Immediately play (without downloading) with assotiated mediaplayer, from yesterday 03:00AM, at the maximum available resolution: 398 | > yrewind -u=oJUvTVdTMyY -s=Y:0300 -r=max -o=.m3u -e=*output* 399 | 400 | Wait for a new livestream on the specified channel, then start recording from the 30th minute: 401 | > yrewind -u=https://www.youtube.com/c/SkyNews -s=+30 402 | 403 | Batch file example (runs 3 copies of the program at the same time): 404 | 405 | @echo off 406 | set O='d:\Streams\' 407 | set B='c:\Program Files\Chrome\chrome.exe' 408 | set F='c:\Program Files\FFmpeg\' 409 | set Y='c:\Program Files\ee.Yrewind\' 410 | cd /d %Y% 411 | 412 | set U=oJUvTVdTMyY 413 | start yrewind -f=%F% -b=%B% -o=%O% -u=%U% -s=Y:1500 414 | start yrewind -f=%F% -b=%B% -o=%O% -u=%U% -s=Y:1600 415 | start yrewind -f=%F% -b=%B% -o=%O% -u=%U% -s=Y:1700 416 | 417 | 418 | 419 | # Notes 420 | 421 | * Loss of packets on the streamer side causes the estimated time to shift. The offset is usually seconds, but if its internet connection is unstable and/or the stream has been running for a long time, it can be minutes or even hours. For example, if the stream was interrupted for a total of 1 hour, then 24-hour frames will be presented as 23-hour. Thus, start point time accuracy can only be guaranteed for the current moment. The further the livestream is rewound, the less accuracy. Also, if there are interruptions in the livestream at the specified time interval, the duration of the saved file will be shorter by the total duration of those interruptions; a warning for this incompleted file will be displayed 422 | * Occasionally, the message `unable to verify the saved file is correct` appears. The reasons may be as follows: if the duration of the saved file cannot be verified (there is a possibility that the file is damaged); if the duration of the saved file does not match the requested one (also in this case, the output file name contains the word *INCOMPLETE*); if the starting point of the requested time interval cannot be accurately determined (for example due to server side error) 423 | * To reduce the chance of output file corruption, it's better not to use Moov Atom formats (`.3gp`, `.mov`, `.mp4`, `.m4a`) for long recordings. Also, these formats don't allow to play a file that is in the process of downloading (other formats do) 424 | * Recently finished livestream can be downloaded within approximately 6 hours of its completion. After this time, such stream 'turns' into a regular video and can be downloaded, for example, using Youtube-dl 425 | * When using proxy, VPN or special firewall settings, keep in mind that not only Yrewind should have appropriate access, but also FFmpeg 426 | 427 | 428 | 429 | # Terms of use 430 | 431 | * This software provides access to information on the Internet that is publicly available and does not require authorization or authentication 432 | * This software is free for non-commercial use and is provided 'as is' without warranty of any kind, either express or implied 433 | * The author will not be liable for data loss, damages or any other kind of loss while using or misusing this software 434 | * The author will not be liable for the misuse of content obtained using this software, including copyrighted, age-restricted, or any other protected content 435 | 436 | 437 | 438 | # Developer info 439 | 440 | * C# 441 | * .NET Framework 4.8 442 | * Visual Studio Community 2022 443 | 444 | 445 | 446 | # Requirements 447 | 448 | * FFmpeg static build (included in the archive) 449 | * Windows 7 and on / Windows Server 2008 R2 and on 450 | * Chromium-based browser 451 | 452 | 453 | 454 | # Tested configuration 455 | 456 | * FFmpeg 4.3 x86 (by Zeranoe) 457 | * Windows 10 Pro x32 458 | * Windows 10 Pro x64 459 | 460 | 461 | 462 | # About 463 | 464 | Console utility for saving YouTube live streams with rewind function up to 167 hours 465 | 466 | 467 | 468 | # Tags 469 | 470 | download downloader dvr live livestream record rewind save stream youtube 471 | 472 | --- 473 | [program page](https://rytsikau.github.io/ee.Yrewind) [start page](https://rytsikau.github.io) [author e-mail](y.rytsikau@gmail.com) 474 | "; 475 | #endregion 476 | 477 | #region [misc] - Paths, URLs, internal presets, etc. 478 | 479 | // Program info 480 | public const string Name = "ee.Yrewind"; 481 | public const int BuildDate = 240622; 482 | public static readonly string Version = 483 | Assembly.GetExecutingAssembly().GetName().Version.ToString(); 484 | 485 | // Random substring for temp files naming 486 | public static readonly string RandomString = 487 | Guid.NewGuid().ToString().Replace("-", "").Replace("+", "").Substring(0, 7); 488 | 489 | // Cache file path 490 | public static readonly string PathCache = 491 | Path.GetTempPath() + Constants.Name.ToLower() + "_" + Constants.Version + ".cache"; 492 | 493 | // Browser netlog path 494 | public static readonly string PathNetlog = 495 | Path.GetTempPath() + Constants.Name.ToLower() + "_" + Constants.RandomString + ".netlog"; 496 | 497 | // URL of one-line file with info about latest release, content like: 498 | // 240622;https://github.com/rytsikau/ee.Yrewind/releases/download/20240622/ee.yrewind_24.061.zip 499 | public const string UrlUpdate = 500 | "https://raw.githubusercontent.com/rytsikau/ee.Yrewind/main/_updated.log"; 501 | 502 | // Other URLs 503 | public const string UrlProxy = "http://localhost:7799/"; 504 | public const string UrlMain = "https://www.youtube.com/"; 505 | public const string UrlStream = "https://www.youtube.com/watch?v=[stream_id]"; 506 | public const string UrlStreamCover = "https://img.youtube.com/vi/[stream_id]/0.jpg"; 507 | public const string UrlStreamOembed = 508 | "https://www.youtube.com/oembed?url=http://youtube.com/watch?v=[stream_id]"; 509 | public const string UrlChannel = "https://www.youtube.com/channel/[channel_id]"; 510 | public const string UrlChannelCheck = 511 | "https://www.youtube.com/feeds/videos.xml?channel_id=[channel_id]"; 512 | 513 | // Other constants 514 | public const int DurationMin = 1; 515 | public const int DurationMax = 18000; 516 | public const int DurationDefault = 3600; 517 | public const int ResolutionMin = 1; 518 | public const int ResolutionMax = 9999; 519 | public const int ResolutionDefault = 1080; 520 | public const int RealTimeBuffer = 60; 521 | public const int FfmpegConsoleWidthMin = 105; 522 | public const int FfmpegTimeout = 120; 523 | public const int CacheShelflifeMinutes = 100; 524 | public const int RewindMaxHours = 167; 525 | public const string BrowserDefault = "msedge"; 526 | public const string OutputDirDefault = "saved_streams"; 527 | public const string OutputExtDefault = ".mkv"; 528 | public const string StartBeginning = "20010101:000000"; 529 | 530 | #endregion 531 | } 532 | } -------------------------------------------------------------------------------- /source/Preparer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Diagnostics; 4 | using System.Linq; 5 | using System.Net; 6 | using System.Runtime.Serialization.Json; 7 | using System.Text; 8 | using System.Text.RegularExpressions; 9 | using System.Web; 10 | using System.Xml; 11 | using System.Xml.Linq; 12 | using System.Xml.XPath; 13 | 14 | namespace yrewind 15 | { 16 | // Various preparations based on command line request and technical info about the stream 17 | class Preparer 18 | { 19 | // Author name / Channel title 20 | public static string Author { get; private set; } 21 | 22 | // Stream title 23 | public static string Title { get; private set; } 24 | 25 | // Available resolutions 26 | public static string Resolutions { get; private set; } 27 | 28 | // Stream start point 29 | // (for ongoing streams this point assumes no breaks caused by network errors, 30 | // so it may be later than the actual/declared stream start) 31 | public static DateTime IdStart { get; private set; } 32 | 33 | // Start and stop timestamps (used for finished streams) 34 | public static DateTime IdStartTimeStamp { get; private set; } 35 | public static DateTime IdStopTimeStamp { get; private set; } 36 | 37 | // Sequence duration 38 | public static int SeqDuration { get; private set; } 39 | 40 | // Duration to download 41 | public static int Duration { get; private set; } 42 | 43 | // Resolution to download 44 | public static int Resolution { get; private set; } 45 | 46 | // Start point to download 47 | public static DateTime Start { get; private set; } 48 | 49 | // Start sequence to download 50 | public static int StartSeq { get; private set; } 51 | 52 | // Direct URLs of current sequences 53 | public static string UriAdirect { get; private set; } 54 | public static string UriVdirect { get; private set; } 55 | 56 | // Other variables 57 | string hlsManifestUrl; 58 | int currentSeq = -1; 59 | DateTime currentSeqUtc; 60 | 61 | #region Common - Main method of the class 62 | public int Common() 63 | { 64 | int code; 65 | Author = string.Empty; 66 | Title = string.Empty; 67 | Resolutions = string.Empty; 68 | UriAdirect = string.Empty; 69 | UriVdirect = string.Empty; 70 | 71 | if (!string.IsNullOrEmpty(Waiter.UriAdirect) & !string.IsNullOrEmpty(Waiter.UriVdirect)) 72 | { 73 | ParseBrowserUris(); 74 | } 75 | 76 | ParseHtmlJson(); 77 | 78 | if (Author == string.Empty || Title == string.Empty) 79 | { 80 | code = GetInfoWithOembed(); 81 | if (code != 0) return code; 82 | } 83 | 84 | GetInfoWithAsegment(); 85 | 86 | if (!string.IsNullOrEmpty(hlsManifestUrl) && 87 | (SeqDuration == default || IdStart == default)) GetInfoWithHls(); 88 | 89 | if (Validator.Log) 90 | { 91 | var logInfo = 92 | "\nPreparer 1:" + 93 | "\nAuthor: " + Author + 94 | "\nDuration: " + Duration + 95 | "\nIdStart: " + IdStart + 96 | "\nIdStartTimeStamp: " + IdStartTimeStamp + 97 | "\nIdStopTimeStamp: " + IdStopTimeStamp + 98 | "\nResolution: " + Resolution + 99 | "\nResolutions: " + Resolutions + 100 | "\nSeqDuration: " + SeqDuration + 101 | "\nStart: " + Start + 102 | "\nStartSeq: " + StartSeq + 103 | "\nTitle: " + Title + 104 | "\nUriAdirect: " + UriAdirect + 105 | "\nUriVdirect: " + UriVdirect; 106 | Program.Log(logInfo); 107 | } 108 | 109 | // Just in case, check again 110 | if (string.IsNullOrEmpty(Author) || 111 | string.IsNullOrEmpty(Title) || 112 | string.IsNullOrEmpty(Resolutions) || 113 | string.IsNullOrEmpty(UriAdirect) || 114 | string.IsNullOrEmpty(UriVdirect) || 115 | SeqDuration == default || 116 | IdStart == default) 117 | { 118 | // "Cannot get live stream information" 119 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 120 | return 9310; 121 | } 122 | 123 | code = GetDuration(); 124 | if (code != 0) return code; 125 | 126 | code = GetResolution(); 127 | if (code != 0) return code; 128 | 129 | if (Resolution > 0) 130 | { 131 | code = CorrectUriVdirect(); 132 | if (code != 0) return code; 133 | } 134 | 135 | code = FindStart(); 136 | if (code != 0) return code; 137 | 138 | if (Validator.Log) 139 | { 140 | var logInfo = 141 | "\nPreparer 2:" + 142 | "\nAuthor: " + Author + 143 | "\nDuration: " + Duration + 144 | "\nIdStart: " + IdStart + 145 | "\nIdStartTimeStamp: " + IdStartTimeStamp + 146 | "\nIdStopTimeStamp: " + IdStopTimeStamp + 147 | "\nResolution: " + Resolution + 148 | "\nResolutions: " + Resolutions + 149 | "\nSeqDuration: " + SeqDuration + 150 | "\nStart: " + Start + 151 | "\nStartSeq: " + StartSeq + 152 | "\nTitle: " + Title + 153 | "\nUriAdirect: " + UriAdirect + 154 | "\nUriVdirect: " + UriVdirect; 155 | Program.Log(logInfo); 156 | } 157 | 158 | return 0; 159 | } 160 | #endregion 161 | 162 | #region ParseBrowserUris - Get [Resolutions], [UriAdirect], [UriVdirect] from browser netlog 163 | int ParseBrowserUris() 164 | { 165 | // Get [UriAdirect] and [UriVdirect] 166 | UriAdirect = Regex.Replace(Waiter.UriAdirect, @"&sq=\d+", ""); 167 | UriVdirect = Regex.Replace(Waiter.UriVdirect, @"&sq=\d+", ""); 168 | 169 | // Get Resolutions from [UriVdirect] 170 | try 171 | { 172 | var resolutions = new ArrayList(); 173 | var itags = Regex.Match(UriVdirect, @".*&aitags=([^&]+).*").Groups[1].Value; 174 | 175 | foreach (var itag in itags.Replace("%2C", ",").Split(',')) 176 | { 177 | if (!int.TryParse(itag, out var itagNumber)) continue; 178 | if (!Constants.Itag.TryGetValue(itagNumber, out var value)) continue; 179 | var resolutionStr = value.Split(';')[2]; 180 | if (resolutionStr.Contains("p")) 181 | { 182 | var resolution = int.Parse(resolutionStr.Split('p')[0]); 183 | if (!resolutions.Contains(resolution)) resolutions.Add(resolution); 184 | } 185 | } 186 | resolutions.Sort(); 187 | resolutions.Reverse(); 188 | Resolutions = string.Join(",", resolutions.ToArray()); 189 | } 190 | catch (Exception e) 191 | { 192 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 193 | if (Validator.Log) Program.Log(Program.ErrInfo); 194 | 195 | // "Cannot get live stream information with browser" 196 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 197 | return 9311; 198 | } 199 | 200 | return 0; 201 | } 202 | #endregion 203 | 204 | #region ParseHtmlJson - Get various values from HTML JSON 205 | void ParseHtmlJson() 206 | { 207 | if (Author == string.Empty) 208 | { 209 | try 210 | { 211 | Author = Waiter.JsonHtml.XPathSelectElement("//author").Value; 212 | Author = Program.Replace_InvalidChars(Author); 213 | } 214 | catch (Exception e) 215 | { 216 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 217 | if (Validator.Log) Program.Log(Program.ErrInfo); 218 | } 219 | } 220 | 221 | if (Title == string.Empty) 222 | { 223 | try 224 | { 225 | Title = Waiter.JsonHtml.XPathSelectElement("//title").Value; 226 | Title = Program.Replace_InvalidChars(Title); 227 | } 228 | catch (Exception e) 229 | { 230 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 231 | if (Validator.Log) Program.Log(Program.ErrInfo); 232 | } 233 | 234 | } 235 | 236 | if (Resolutions == string.Empty) 237 | { 238 | try 239 | { 240 | var tmp = Waiter.JsonHtml.XPathSelectElements 241 | ("//adaptiveFormats/*/height").Select(x => x.Value).Distinct(); 242 | Resolutions = string.Join(",", tmp); 243 | } 244 | catch (Exception e) 245 | { 246 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 247 | if (Validator.Log) Program.Log(Program.ErrInfo); 248 | } 249 | 250 | } 251 | 252 | if (IdStartTimeStamp == default) 253 | { 254 | try 255 | { 256 | var tmp = Waiter.JsonHtml.XPathSelectElement("//startTimestamp").Value; 257 | tmp = tmp.Replace(" 00:00", "Z"); 258 | IdStartTimeStamp = DateTime.Parse(tmp).ToLocalTime(); 259 | } 260 | catch (Exception e) 261 | { 262 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 263 | if (Validator.Log) Program.Log(Program.ErrInfo); 264 | } 265 | 266 | } 267 | 268 | if (IdStopTimeStamp == default) 269 | { 270 | try 271 | { 272 | var tmp = Waiter.JsonHtml.XPathSelectElement("//endTimestamp").Value; 273 | tmp = tmp.Replace(" 00:00", "Z"); 274 | IdStopTimeStamp = DateTime.Parse(tmp).ToLocalTime(); 275 | } 276 | catch (Exception e) 277 | { 278 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 279 | if (Validator.Log) Program.Log(Program.ErrInfo); 280 | } 281 | 282 | } 283 | 284 | if (SeqDuration == default) 285 | { 286 | try 287 | { 288 | SeqDuration = 289 | int.Parse(Waiter.JsonHtml.XPathSelectElement("//targetDurationSec").Value); 290 | } 291 | catch (Exception e) 292 | { 293 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 294 | if (Validator.Log) Program.Log(Program.ErrInfo); 295 | } 296 | 297 | } 298 | 299 | if (UriAdirect == string.Empty) 300 | { 301 | try 302 | { 303 | UriAdirect = Waiter.JsonHtml.XPathSelectElement 304 | ("//adaptiveFormats/*/url[contains(text(),'mime=audio')]").Value; 305 | } 306 | catch (Exception e) 307 | { 308 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 309 | if (Validator.Log) Program.Log(Program.ErrInfo); 310 | } 311 | 312 | } 313 | 314 | if (UriVdirect == string.Empty) 315 | { 316 | try 317 | { 318 | UriVdirect = Waiter.JsonHtml.XPathSelectElement 319 | ("//adaptiveFormats/*/url[contains(text(),'mime=video')]").Value; 320 | } 321 | catch (Exception e) 322 | { 323 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 324 | if (Validator.Log) Program.Log(Program.ErrInfo); 325 | } 326 | 327 | } 328 | 329 | if (hlsManifestUrl == string.Empty) 330 | { 331 | try 332 | { 333 | hlsManifestUrl = Waiter.JsonHtml.XPathSelectElement("//hlsManifestUrl").Value; 334 | } 335 | catch (Exception e) 336 | { 337 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 338 | if (Validator.Log) Program.Log(Program.ErrInfo); 339 | } 340 | } 341 | } 342 | #endregion 343 | 344 | #region GetInfoWithOembed - Get [Author] and [Title] from oembed page 345 | int GetInfoWithOembed() 346 | { 347 | var content = string.Empty; 348 | XElement jsonOembed; 349 | 350 | try 351 | { 352 | var uri = Constants.UrlStreamOembed.Replace("[stream_id]", Waiter.Id); 353 | 354 | using (var wc = new WebClient()) 355 | { 356 | content = wc.DownloadString(new Uri(uri)); 357 | } 358 | } 359 | catch (WebException e) 360 | { 361 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 362 | if (Validator.Log) Program.Log(Program.ErrInfo); 363 | 364 | // "Cannot get live stream information" 365 | return 9310; 366 | } 367 | 368 | if (Validator.Log) Program.Log(content, "oembed"); 369 | 370 | try 371 | { 372 | var tmp1 = Encoding.UTF8.GetBytes(HttpUtility.UrlDecode(content)); 373 | var tmp2 = JsonReaderWriterFactory 374 | .CreateJsonReader(tmp1, new XmlDictionaryReaderQuotas()); 375 | jsonOembed = XElement.Load(tmp2); 376 | } 377 | catch (Exception e) 378 | { 379 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 380 | if (Validator.Log) Program.Log(Program.ErrInfo); 381 | 382 | // "Cannot get live stream information" 383 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 384 | return 9310; 385 | } 386 | 387 | try 388 | { 389 | Author = jsonOembed.XPathSelectElement("//author_name").Value; 390 | Author = Program.Replace_InvalidChars(Author); 391 | 392 | Title = jsonOembed.XPathSelectElement("//title").Value; 393 | Title = Program.Replace_InvalidChars(Title); 394 | } 395 | catch (Exception e) 396 | { 397 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 398 | if (Validator.Log) Program.Log(Program.ErrInfo); 399 | 400 | // "Cannot get live stream information" 401 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 402 | return 9310; 403 | } 404 | 405 | return 0; 406 | } 407 | #endregion 408 | 409 | #region GetInfoWithAsegment - Get [SeqDuration] and [IdStart] from audio segment 410 | void GetInfoWithAsegment() 411 | { 412 | string content; 413 | 414 | try 415 | { 416 | using (var wc = new WebClient()) 417 | { 418 | content = wc.DownloadString(new Uri(UriAdirect)); 419 | } 420 | } 421 | catch (WebException e) 422 | { 423 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 424 | if (Validator.Log) Program.Log(Program.ErrInfo); 425 | 426 | return; 427 | } 428 | 429 | if (Validator.Log) Program.Log(content, "segmentA"); 430 | 431 | // Parse audio segment as text - get sequence number, its UTC time and duration 432 | if (SeqDuration == default) 433 | { 434 | try 435 | { 436 | var tmp = Regex.Match(content, @".*Target-Duration-Us: (\d+).*") 437 | .Groups[1].Value; 438 | SeqDuration = int.Parse(tmp) / 1000000; 439 | } 440 | catch (Exception e) 441 | { 442 | Program.ErrInfo = 443 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 444 | if (Validator.Log) Program.Log(Program.ErrInfo); 445 | 446 | SeqDuration = default; 447 | } 448 | } 449 | if (currentSeq == -1) 450 | { 451 | try 452 | { 453 | // For finished streams it's number of last sequence 454 | var tmp = Regex.Match(content, @".*Sequence-Number: (\d+).*") 455 | .Groups[1].Value; 456 | currentSeq = int.Parse(tmp); 457 | } 458 | catch (Exception e) 459 | { 460 | Program.ErrInfo = 461 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 462 | if (Validator.Log) Program.Log(Program.ErrInfo); 463 | 464 | currentSeq = -1; 465 | } 466 | } 467 | if (currentSeqUtc == default) 468 | { 469 | try 470 | { 471 | // For finished streams it's time of last sequence 472 | var tmp = Regex.Match(content, @".*Ingestion-Walltime-Us: (\d+).*") 473 | .Groups[1].Value; 474 | currentSeqUtc = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); 475 | currentSeqUtc = currentSeqUtc.AddSeconds(long.Parse(tmp) / 1000000); 476 | } 477 | catch (Exception e) 478 | { 479 | Program.ErrInfo = 480 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 481 | if (Validator.Log) Program.Log(Program.ErrInfo); 482 | 483 | currentSeqUtc = default; 484 | } 485 | } 486 | 487 | if (SeqDuration == default || currentSeq == -1 || currentSeqUtc == default) 488 | { 489 | return; 490 | } 491 | 492 | IdStart = currentSeqUtc.AddSeconds(-currentSeq * SeqDuration).ToLocalTime(); 493 | } 494 | #endregion 495 | 496 | #region GetInfoWithHls - Get [SeqDuration] and [IdStart] from HLS manifest and playlist 497 | void GetInfoWithHls() 498 | { 499 | string content; 500 | string hlsPlaylistUrl = default; 501 | 502 | // Download HLS manifest 503 | try 504 | { 505 | using (var wc = new WebClient()) 506 | { 507 | content = wc.DownloadString(new Uri(hlsManifestUrl)); 508 | } 509 | } 510 | catch (WebException e) 511 | { 512 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 513 | if (Validator.Log) Program.Log(Program.ErrInfo); 514 | 515 | return; 516 | } 517 | 518 | if (Validator.Log) Program.Log(content, "m3u8_1"); 519 | 520 | // Find HLS playlist URL in the manifest 521 | foreach (var line in content.Split('\n')) 522 | { 523 | if (Regex.IsMatch(line, @"^https.+m3u8$")) 524 | { 525 | hlsPlaylistUrl = line; 526 | break; 527 | } 528 | } 529 | 530 | // Download HLS playlist 531 | try 532 | { 533 | using (var wc = new WebClient()) 534 | { 535 | content = wc.DownloadString(new Uri(hlsPlaylistUrl)); 536 | } 537 | } 538 | catch (WebException e) 539 | { 540 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 541 | if (Validator.Log) Program.Log(Program.ErrInfo); 542 | 543 | return; 544 | } 545 | 546 | if (Validator.Log) Program.Log(content, "m3u8_2"); 547 | 548 | // Parse HLS playlist 549 | if (SeqDuration == default) 550 | { 551 | try 552 | { 553 | var tmp = Regex.Match(content, @".*#EXT-X-TARGETDURATION:(\d+).*") 554 | .Groups[1].Value; 555 | SeqDuration = int.Parse(tmp); 556 | } 557 | catch (Exception e) 558 | { 559 | Program.ErrInfo = 560 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 561 | if (Validator.Log) Program.Log(Program.ErrInfo); 562 | 563 | SeqDuration = default; 564 | } 565 | } 566 | if (currentSeq == -1) 567 | { 568 | try 569 | { 570 | // For finished streams it's number of first sequence 571 | var tmp = Regex.Match(content, @".*#EXT-X-MEDIA-SEQUENCE:(\d+).*") 572 | .Groups[1].Value; 573 | currentSeq = int.Parse(tmp); 574 | } 575 | catch (Exception e) 576 | { 577 | Program.ErrInfo = 578 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 579 | if (Validator.Log) Program.Log(Program.ErrInfo); 580 | 581 | currentSeq = -1; 582 | } 583 | } 584 | if (currentSeqUtc == default) 585 | { 586 | try 587 | { 588 | // For finished streams it's time of first sequence 589 | var tmp = Regex.Match(content, @".*#EXT-X-PROGRAM-DATE-TIME:(.+)\+.*") 590 | .Groups[1].Value; 591 | currentSeqUtc = DateTime.Parse(tmp); 592 | } 593 | catch (Exception e) 594 | { 595 | Program.ErrInfo = 596 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 597 | if (Validator.Log) Program.Log(Program.ErrInfo); 598 | 599 | currentSeqUtc = default; 600 | } 601 | } 602 | 603 | if (SeqDuration == default || currentSeq == -1 || currentSeqUtc == default) 604 | { 605 | return; 606 | } 607 | 608 | // Sometimes HLS playlist contains incorrect UTC time of the current sequence. 609 | // Fixing this, we cannot demand an exact match, because the stream is always 610 | // broadcast with delay of several tens of seconds. 611 | // So, we will set an acceptable margin, for example, 3 minutes. 612 | if ((Waiter.IdStatus != Waiter.Stream.Finished) && 613 | ((DateTime.UtcNow - currentSeqUtc) > TimeSpan.FromMinutes(3))) 614 | { 615 | currentSeqUtc = Program.Start.ToUniversalTime() + Program.Timer.Elapsed; 616 | Program.ResultIsOK = false; 617 | } 618 | 619 | IdStart = currentSeqUtc.AddSeconds(-currentSeq * SeqDuration).ToLocalTime(); 620 | } 621 | #endregion 622 | 623 | #region GetDuration - Determine [Duration] 624 | int GetDuration() 625 | { 626 | double numberOfSequences; 627 | 628 | if (Waiter.IdStatus == Waiter.Stream.Finished) 629 | { 630 | numberOfSequences = 631 | (IdStopTimeStamp - IdStartTimeStamp).TotalSeconds / SeqDuration; 632 | } 633 | else 634 | { 635 | numberOfSequences = 636 | double.Parse(Validator.Duration) / SeqDuration; 637 | } 638 | 639 | // Round up to the greater number of sequences 640 | Duration = (int)Math.Ceiling(numberOfSequences) * SeqDuration; 641 | if (Duration > Constants.DurationMax) Duration -= SeqDuration; 642 | 643 | return 0; 644 | } 645 | #endregion 646 | 647 | #region GetResolution - Determine [Resolution] to download 648 | int GetResolution() 649 | { 650 | // To save audio only 651 | if ((Validator.OutputExt == ".aac") || 652 | (Validator.OutputExt == ".m4a") || 653 | (Validator.OutputExt == ".wma") || 654 | (Validator.Resolution == "0")) 655 | { 656 | Resolution = 0; 657 | return 0; 658 | } 659 | 660 | // Other formats 661 | try 662 | { 663 | var resolutionTmp = 0; 664 | 665 | foreach (var item in Preparer.Resolutions.Split(',')) 666 | { 667 | resolutionTmp = int.Parse(item); 668 | if (resolutionTmp > int.Parse(Validator.Resolution)) continue; 669 | else break; 670 | } 671 | 672 | Resolution = resolutionTmp; 673 | } 674 | catch (Exception e) 675 | { 676 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 677 | if (Validator.Log) Program.Log(Program.ErrInfo); 678 | 679 | // "Cannot get live stream information" 680 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 681 | return 9310; 682 | } 683 | 684 | // Resolutions above 1080 are only available in VP9 adaptive format ('webm'), 685 | // but for some media containers, FFmpeg cannot store VP9 and MP4a data together. 686 | // Therefore, we will use for them 1080 even if a higher was requested 687 | if (Resolution > 1080) 688 | { 689 | if ((Validator.OutputExt == ".3gp") || 690 | (Validator.OutputExt == ".flv") || 691 | (Validator.OutputExt == ".mov") || 692 | (Validator.OutputExt == ".ts")) 693 | { 694 | Resolution = 1080; 695 | } 696 | } 697 | 698 | return 0; 699 | } 700 | #endregion 701 | 702 | #region CorrectUriVdirect - Get [UriVdirect] with required 'itag' 703 | int CorrectUriVdirect() 704 | { 705 | // Get [UriVdirect] with the correct 'itag' parameter, 706 | // according to the selected resolution and preferred adaptive format 707 | 708 | var itag = string.Empty; 709 | var itags = Regex.Match(UriVdirect, @".*&aitags=([^&]+).*").Groups[1].Value; 710 | var preferredAdaptiveFormat = "mp4"; 711 | if ((Validator.OutputExt == ".asf") || 712 | (Validator.OutputExt == ".mkv") || 713 | (Validator.OutputExt == ".wmv")) 714 | { 715 | // For ASF, MKV and WMV containers prefer 'webm' format 716 | preferredAdaptiveFormat = "webm"; 717 | } 718 | 719 | try 720 | { 721 | foreach (var item in itags.Replace("%2C", ",").Split(',')) 722 | { 723 | if (!int.TryParse(item, out var itemNumber)) continue; 724 | if (!Constants.Itag.TryGetValue(itemNumber, out var itemDescr)) continue; 725 | var itemResolutionStr = itemDescr.Split(';')[2]; 726 | var itemResolution = int.Parse(itemResolutionStr.Split('p')[0]); 727 | if (itemResolution == Resolution) 728 | { 729 | itag = item; 730 | if (itemDescr.Contains(preferredAdaptiveFormat)) break; 731 | } 732 | } 733 | } 734 | catch (Exception e) 735 | { 736 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 737 | if (Validator.Log) Program.Log(Program.ErrInfo); 738 | 739 | // "Cannot get live stream information" 740 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 741 | return 9310; 742 | } 743 | 744 | if (itag != string.Empty) 745 | { 746 | UriVdirect = Regex.Replace(UriVdirect, @"&itag=\d+", @"&itag=" + itag); 747 | } 748 | else 749 | { 750 | // "Cannot get live stream information" 751 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 752 | return 9310; 753 | } 754 | 755 | return 0; 756 | } 757 | #endregion 758 | 759 | #region FindStart - Determine [Start] and [StartSeq] 760 | int FindStart() 761 | { 762 | // Internal using in *getnext* mode 763 | if (Validator.Start.StartsWith("seq")) 764 | { 765 | StartSeq = int.Parse(Validator.Start.Replace("seq", "")); 766 | 767 | if (CheckSeq(StartSeq) == "200") 768 | { 769 | Start = IdStart.AddSeconds(StartSeq * SeqDuration); 770 | return 0; 771 | } 772 | else 773 | { 774 | // "Further parts of the stream are unavailable" 775 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 776 | return 9312; 777 | } 778 | } 779 | 780 | // Determine [Start] 781 | if (string.IsNullOrEmpty(Validator.Start)) 782 | { 783 | if (Waiter.IdStatus == Waiter.Stream.Ongoing) Start = Program.Start; 784 | else Start = IdStart; 785 | } 786 | else if (Validator.Start == Constants.StartBeginning) 787 | { 788 | Start = IdStart; 789 | } 790 | else if (Validator.Start.StartsWith("-")) 791 | { 792 | if (Waiter.IdStatus == Waiter.Stream.Upcoming) 793 | { 794 | Start = IdStart; 795 | } 796 | else if (Waiter.IdStatus == Waiter.Stream.Ongoing) 797 | { 798 | Start = Program.Start.AddMinutes(int.Parse(Validator.Start)); 799 | if (Start < IdStart) Start = IdStart; 800 | } 801 | else if (Waiter.IdStatus == Waiter.Stream.Finished) 802 | { 803 | Start = IdStart; 804 | } 805 | } 806 | else if (Validator.Start.StartsWith("+")) 807 | { 808 | if (Waiter.IdStatus == Waiter.Stream.Upcoming) 809 | { 810 | Start = IdStart.AddMinutes(int.Parse(Validator.Start)); 811 | } 812 | else if (Waiter.IdStatus == Waiter.Stream.Ongoing) 813 | { 814 | Start = Program.Start.AddMinutes(int.Parse(Validator.Start)); 815 | } 816 | else if (Waiter.IdStatus == Waiter.Stream.Finished) 817 | { 818 | Start = IdStart.AddMinutes(int.Parse(Validator.Start)); 819 | } 820 | } 821 | else 822 | { 823 | Start = DateTime.ParseExact(Validator.Start, "yyyyMMdd:HHmmss", null); 824 | 825 | // Round up to the beginning of the next sequence 826 | if (Start > IdStart) 827 | { 828 | var numberOfSequences = (int)((Start - IdStart).TotalSeconds / SeqDuration); 829 | Start = IdStart.AddSeconds(numberOfSequences * SeqDuration + SeqDuration); 830 | } 831 | } 832 | 833 | // Some checks and corrections 834 | if (Start < IdStart) 835 | { 836 | // "Requested time interval isn't available" 837 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + 838 | " - requested " + Start + " / stream started " + IdStart; 839 | return 9313; 840 | } 841 | else if (Waiter.IdStatus == Waiter.Stream.Ongoing) 842 | { 843 | // For long ongoing streams, sequences 'aged' 167-168 hours 844 | // become unavailable randomly (not in order), so we trim the unstable interval 845 | var earliestStable = (Program.Start + Program.Timer.Elapsed).AddHours(-Constants.RewindMaxHours); 846 | if (Start < earliestStable) 847 | { 848 | if (Validator.Start == Constants.StartBeginning) 849 | { 850 | Start = earliestStable; 851 | } 852 | else 853 | { 854 | // "Requested time interval isn't available" 855 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + 856 | " - requested " + Start + " / available " + earliestStable; 857 | return 9313; 858 | } 859 | } 860 | } 861 | 862 | // Determine [StartSeq] (round up to the beginning of the next sequence) 863 | StartSeq = (int)Math.Ceiling((Start - IdStart).TotalSeconds / SeqDuration); 864 | 865 | // If the sequence is not YET available, just return to Program 866 | if ((Program.Start + Program.Timer.Elapsed) < 867 | Start.AddSeconds(Constants.RealTimeBuffer)) 868 | { 869 | return 0; 870 | } 871 | else if (CheckSeq(StartSeq) != "200") 872 | { 873 | // "Requested time interval isn't available" 874 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 875 | return 9313; 876 | } 877 | 878 | return 0; 879 | } 880 | #endregion 881 | 882 | #region CheckSeq - Check availability of the specified sequence 883 | public static string CheckSeq(object arg) 884 | { 885 | // Input argument as to be able to use this method as a task 886 | int seqNum = (int)arg; 887 | 888 | string result; 889 | 890 | // Check audio sequence 891 | var uriA = UriAdirect + "&sq=" + seqNum; 892 | try 893 | { 894 | var req = (HttpWebRequest)WebRequest.Create(new Uri(uriA)); 895 | var res = (HttpWebResponse)req.GetResponse(); 896 | result = ((int)res.StatusCode).ToString(); 897 | res.Close(); 898 | } 899 | catch (WebException e) 900 | { 901 | result = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 902 | } 903 | 904 | if (Validator.Log) 905 | { 906 | Program.Log("Check status of audio sequence " + seqNum + "... " + result); 907 | } 908 | 909 | if (result == "200") return result; 910 | 911 | // If any error, check also video sequence 912 | var uriV = UriVdirect + "&sq=" + seqNum; 913 | try 914 | { 915 | var req = (HttpWebRequest)WebRequest.Create(new Uri(uriV)); 916 | var res = (HttpWebResponse)req.GetResponse(); 917 | result = ((int)res.StatusCode).ToString(); 918 | res.Close(); 919 | } 920 | catch (WebException e) 921 | { 922 | result = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 923 | } 924 | 925 | if (Validator.Log) 926 | { 927 | Program.Log("Check status of video sequence " + seqNum + "... " + result); 928 | } 929 | 930 | return result; 931 | } 932 | #endregion 933 | } 934 | } -------------------------------------------------------------------------------- /source/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Net; 5 | using System.Reflection; 6 | using System.Text; 7 | using System.Text.RegularExpressions; 8 | using System.Threading; 9 | using System.Web; 10 | 11 | namespace yrewind 12 | { 13 | // Main class 14 | static class Program 15 | { 16 | // Exact time when program started (local time zone) and program timer 17 | public static DateTime Start { get; private set; } 18 | public static Stopwatch Timer = new Stopwatch(); 19 | 20 | // Output paths 21 | public static string OutputDir { get; private set; } 22 | public static string OutputName { get; private set; } 23 | public static string OutputExt { get; private set; } 24 | 25 | // TRUE if: 26 | // FFmpeg process exited correctly AND 27 | // duration of the saved media is verifiable AND 28 | // duration of the saved media is as requested AND 29 | // the start time point is determined accurately (using UTC in the stream technical info) 30 | public static bool? ResultIsOK; 31 | 32 | // Output folder for log files 33 | public static string LogDir { get; private set; } 34 | 35 | // Additional info about the error 36 | public static string ErrInfo; 37 | 38 | // Other variables 39 | static bool youTubeAvailable; 40 | static bool? isUpdateExists; 41 | static string updateDirectUrl; 42 | 43 | #region Main - Entry point of the program 44 | static void Main() 45 | { 46 | Start = DateTime.Now; 47 | 48 | Timer.Start(); 49 | int code; 50 | 51 | // Console settings 52 | Console.OutputEncoding = Encoding.UTF8; 53 | Console.Title = Constants.Name; 54 | if (Console.CursorLeft != 0) Console.WriteLine(); 55 | int consoleWidthInit = Console.WindowWidth; 56 | if (Console.WindowWidth < Constants.FfmpegConsoleWidthMin) 57 | { 58 | // To prevent FFmpeg from printing a new 'stats' line every second 59 | Console.SetWindowSize(Constants.FfmpegConsoleWidthMin, Console.WindowHeight); 60 | } 61 | 62 | // If the program was launched without args 63 | if (Environment.GetCommandLineArgs().Length == 1) 64 | { 65 | WithoutArgs(); 66 | 67 | // Standard Windows code "Incorrect function" 68 | Exit(1); 69 | } 70 | 71 | // Determine exact current time and YouTube availability 72 | // (to get server response without bulk content just send wrong URL) 73 | DownloadString(Constants.UrlMain + "~", 1, 1, out string msg); 74 | if (msg.Contains("\t")) 75 | { 76 | try 77 | { 78 | var utcStr = msg.Split('\t')[0]; 79 | Start = DateTime.Parse(utcStr) - Program.Timer.Elapsed; 80 | youTubeAvailable = true; 81 | } 82 | catch (Exception e) 83 | { 84 | Program.ErrInfo = 85 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 86 | if (Validator.Log) Program.Log(Program.ErrInfo); 87 | } 88 | } 89 | else 90 | { 91 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + msg; 92 | if (Validator.Log) Program.Log(Program.ErrInfo); 93 | } 94 | 95 | // "--> {0} (started at {1})" 96 | Console.ForegroundColor = ConsoleColor.Cyan; 97 | Console.WriteLine(Constants.Msg[9052], Constants.Name, Start.ToString()); 98 | Console.ResetColor(); 99 | 100 | // If the program should work 101 | if (!youTubeAvailable) 102 | { 103 | // "YouTube not responding" 104 | Exit(9075); 105 | } 106 | 107 | // Output folder for log files 108 | LogDir = Directory.GetCurrentDirectory() + "\\" + 109 | Start.ToString("yyyyMMdd-HHmmss") + "\\"; 110 | 111 | // Parse the command line input 112 | var argsLine = Environment.CommandLine; 113 | if (argsLine.Length > 8191) 114 | { 115 | // "Command line input is too long" 116 | Exit(9059); 117 | } 118 | var validator = new Validator(); 119 | code = validator.ParseArgsLine(argsLine); 120 | if (code != 0) Exit(code); 121 | 122 | // "Getting live stream info" 123 | Console.Write(Constants.Msg[9065].ToLower() + "..."); 124 | 125 | // Wait for stream, get HTML JSON 126 | var waiter = new Waiter(); 127 | code = waiter.Common(); 128 | if (code != 0) Exit(code); 129 | 130 | // Get ID info and make various preparations and checks 131 | var preparer = new Preparer(); 132 | while (true) 133 | { 134 | code = preparer.Common(); 135 | if (code != 0) Exit(code); 136 | 137 | // If start point is not yet available 138 | var secondsToWait = (long)((Preparer.Start - (Program.Start + 139 | Program.Timer.Elapsed)).TotalSeconds + Constants.RealTimeBuffer); 140 | if (secondsToWait > 0) Program.CountdownTimer(secondsToWait); 141 | 142 | // If the waiting time was long, run Preparer again 143 | if (secondsToWait < 100) break; 144 | } 145 | 146 | // New line after "Getting live stream info" and possible сountdown timer 147 | Console.WriteLine(); 148 | 149 | // "Stream title" 150 | Console.WriteLine(" {0,-20}{1}", Constants.Msg[9080].ToLower(), Preparer.Title); 151 | 152 | // "Resolutions" 153 | Console.WriteLine(" {0,-20}{1}", Constants.Msg[9076].ToLower(), Preparer.Resolutions); 154 | 155 | // Determine output directory 156 | OutputDir = ReplaceWildcards(Validator.OutputDir); 157 | 158 | // Determine output filename 159 | if (string.IsNullOrEmpty(Validator.OutputName)) 160 | { 161 | // Set default like 'oJUvTVdTMyY_20201231-073000_060m00s_0720p' 162 | var d = TimeSpan.FromSeconds(Preparer.Duration); 163 | 164 | OutputName = 165 | Waiter.Id + "_" + 166 | Preparer.Start.ToString("yyyyMMdd-HHmmss") + "_" + 167 | ((int)d.TotalMinutes).ToString("000") + "m" + d.Seconds.ToString("00")+ "s" + "_" + 168 | Preparer.Resolution.ToString("0000") + "p"; 169 | } 170 | else 171 | { 172 | OutputName = ReplaceWildcards(Validator.OutputName); 173 | } 174 | 175 | // Determine output extension 176 | OutputExt = Validator.OutputExt; 177 | 178 | // "Output file" 179 | Console.WriteLine(" {0,-20}{1}", Constants.Msg[9067].ToLower(), 180 | OutputName + OutputExt); 181 | 182 | if (Validator.Log) 183 | { 184 | var logInfo = 185 | "\nOutput folder: " + OutputDir + 186 | "\nOutput file: " + OutputName + OutputExt 187 | ; 188 | Program.Log(logInfo); 189 | } 190 | 191 | // Check if the output file already exists 192 | if (File.Exists(OutputDir + OutputName + OutputExt)) 193 | { 194 | // "File already exists" 195 | Exit(9064); 196 | } 197 | 198 | // Download and save 199 | var getsave = new Saver(); 200 | code = getsave.Common(); 201 | if (code != 0) Exit(code); 202 | 203 | // Show warning if result cannot be verified 204 | if (ResultIsOK != true) 205 | { 206 | // "Unable to verify the saved file is correct" 207 | Console.ForegroundColor = ConsoleColor.Yellow; 208 | Console.WriteLine(Constants.Msg[9082].ToLower()); 209 | Console.ResetColor(); 210 | } 211 | 212 | // Restore console window width 213 | if (Console.WindowWidth != consoleWidthInit) 214 | { 215 | Console.SetWindowSize(consoleWidthInit, Console.WindowHeight); 216 | } 217 | 218 | // Normal exit point 219 | Exit(0); 220 | } 221 | #endregion 222 | 223 | #region WithoutArgs - If the program started without arguments 224 | static void WithoutArgs() 225 | { 226 | Console.CursorVisible = false; 227 | ConsoleKey tmp1, tmp2, tmp3; 228 | 229 | while (true) 230 | { 231 | Console.Clear(); 232 | Console.BufferHeight = Console.WindowHeight; 233 | 234 | // "{0} (version {1})" 235 | Console.WriteLine(Constants.Msg[9051], Constants.Name, Constants.Version); 236 | // "---[64]---" 237 | Console.WriteLine(Constants.Msg[9050]); 238 | // "Press to show help" 239 | Console.WriteLine(Constants.Msg[9071]); 240 | // "Press to get the latest version" 241 | Console.WriteLine(Constants.Msg[9072]); 242 | // "Press to exit" 243 | Console.WriteLine(Constants.Msg[9070]); 244 | // "---[64]---" 245 | Console.WriteLine(Constants.Msg[9050]); 246 | 247 | tmp1 = Console.ReadKey(true).Key; 248 | if (tmp1 == ConsoleKey.Escape) 249 | { 250 | // Exit 251 | 252 | break; 253 | } 254 | else if (tmp1 == ConsoleKey.H) 255 | { 256 | // Show Help 257 | 258 | Console.Clear(); 259 | var BufferHeightExtra = 10; 260 | foreach (var line in Constants.Help.Split('\n')) 261 | { 262 | if (line.Length > Console.WindowWidth) 263 | { 264 | BufferHeightExtra += 265 | (int)Math.Ceiling((double)line.Length / Console.WindowWidth) - 1; 266 | } 267 | } 268 | Console.BufferHeight = Constants.Help.Split('\n').Length + BufferHeightExtra; 269 | 270 | // "{0} (version {1})" 271 | Console.WriteLine(Constants.Msg[9051], Constants.Name, Constants.Version); 272 | // "---[64]---" 273 | Console.WriteLine(Constants.Msg[9050]); 274 | // "Use , , , to scroll" 275 | Console.WriteLine(Constants.Msg[9083]); 276 | // "" 277 | Console.WriteLine(); 278 | // "Press to exit" 279 | Console.WriteLine(Constants.Msg[9070]); 280 | // "---[64]---" 281 | Console.WriteLine(Constants.Msg[9050]); 282 | // "Please see the actual text on the program page" 283 | Console.WriteLine("[" + Constants.Msg[9068] + "]"); 284 | // Show help content 285 | Console.WriteLine(Constants.Help); 286 | 287 | Console.SetWindowPosition(0, 0); 288 | 289 | while (true) 290 | { 291 | tmp2 = Console.ReadKey(true).Key; 292 | if (tmp2 == ConsoleKey.Escape) break; 293 | else if (tmp2 == ConsoleKey.DownArrow) 294 | { 295 | if (Console.WindowTop + Console.WindowHeight < Console.BufferHeight) 296 | Console.WindowTop += 1; 297 | } 298 | else if (tmp2 == ConsoleKey.UpArrow) 299 | { 300 | if (Console.WindowTop > 1) 301 | Console.WindowTop -= 1; 302 | else Console.WindowTop = 0; 303 | } 304 | else if (tmp2 == ConsoleKey.PageDown) 305 | { 306 | if (Console.WindowTop + 2 * Console.WindowHeight < Console.BufferHeight) 307 | Console.WindowTop += Console.WindowHeight; 308 | else Console.WindowTop = Console.BufferHeight - Console.WindowHeight; 309 | } 310 | else if (tmp2 == ConsoleKey.PageUp) 311 | { 312 | if (Console.WindowTop > Console.WindowHeight) 313 | Console.WindowTop -= Console.WindowHeight; 314 | else Console.WindowTop = 0; 315 | } 316 | } 317 | } 318 | else if (tmp1 == ConsoleKey.U) 319 | { 320 | // Check for updates 321 | 322 | Console.Clear(); 323 | Console.BufferHeight = Console.WindowHeight; 324 | 325 | // "{0} (version {1})" 326 | Console.WriteLine(Constants.Msg[9051], Constants.Name, Constants.Version); 327 | // "---[64]---" 328 | Console.WriteLine(Constants.Msg[9050]); 329 | // "Press to confirm download" 330 | Console.WriteLine(Constants.Msg[9069]); 331 | // "" 332 | Console.WriteLine(); 333 | // "Press to exit" 334 | Console.WriteLine(Constants.Msg[9070]); 335 | // "---[64]---" 336 | Console.WriteLine(Constants.Msg[9050]); 337 | // "" 338 | Console.WriteLine(); 339 | // "Checking" 340 | Console.Write(Constants.Msg[9058] + "... "); 341 | var content = DownloadString(Constants.UrlUpdate, 3, 5, out _); 342 | try 343 | { 344 | var updateDate = int.Parse(content.Split(';')[0].Trim()); 345 | if (Constants.BuildDate < updateDate) isUpdateExists = true; 346 | else isUpdateExists = false; 347 | updateDirectUrl = content.Split(';')[1].Trim(); 348 | } 349 | catch (Exception e) 350 | { 351 | Program.ErrInfo = 352 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 353 | if (Validator.Log) Program.Log(Program.ErrInfo); 354 | } 355 | 356 | if (!isUpdateExists.HasValue) 357 | { 358 | // "Unable to check for updates" 359 | Console.ForegroundColor = ConsoleColor.Red; 360 | Console.WriteLine(Constants.Msg[9081]); 361 | } 362 | else if ((bool)isUpdateExists) 363 | { 364 | // "New version available" 365 | Console.ForegroundColor = ConsoleColor.Yellow; 366 | Console.WriteLine(Constants.Msg[9066]); 367 | } 368 | else 369 | { 370 | // "Current version is actual" 371 | Console.ForegroundColor = ConsoleColor.Green; 372 | Console.WriteLine(Constants.Msg[9060]); 373 | } 374 | Console.ResetColor(); 375 | 376 | while (true) 377 | { 378 | tmp3 = Console.ReadKey(true).Key; 379 | if (tmp3 == ConsoleKey.Escape) break; 380 | else if (tmp3 == ConsoleKey.D) 381 | { 382 | // "Downloading, please wait a minute" 383 | Console.Write(Constants.Msg[9062] + "... "); 384 | 385 | var zipFullPath = 386 | Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + 387 | "\\" + 388 | Path.GetFileName(updateDirectUrl); 389 | 390 | var zipFullPathTmp = 391 | zipFullPath.Replace(".zip", "~" + Constants.RandomString + ".zip"); 392 | 393 | if (File.Exists(zipFullPath)) 394 | { 395 | // "Skipped! File already exists" 396 | Console.WriteLine(Constants.Msg[9079]); 397 | //"See file '{0}' on the desktop" 398 | Console.WriteLine("(" + Constants.Msg[9078].ToLower() + ")", 399 | Path.GetFileName(updateDirectUrl)); 400 | 401 | Console.ReadKey(); 402 | break; 403 | } 404 | else 405 | { 406 | try 407 | { 408 | var uri = updateDirectUrl; 409 | using (var wc = new WebClient()) 410 | { 411 | wc.DownloadFile(new Uri(uri), zipFullPathTmp); 412 | } 413 | 414 | File.Move(zipFullPathTmp, zipFullPath); 415 | 416 | // "Ready" 417 | Console.WriteLine(Constants.Msg[9073] + "!"); 418 | //"See file '{0}' on the desktop" 419 | Console.WriteLine("(" + Constants.Msg[9078].ToLower() + ")", 420 | Path.GetFileName(updateDirectUrl)); 421 | } 422 | catch (WebException e) 423 | { 424 | Program.ErrInfo = 425 | new StackFrame(0, true).GetFileLineNumber() + 426 | " - " + e.Message; 427 | if (Validator.Log) Program.Log(Program.ErrInfo); 428 | 429 | // "Error" 430 | Console.WriteLine(Constants.Msg[9063] + "!"); 431 | } 432 | } 433 | } 434 | } 435 | } 436 | } 437 | } 438 | #endregion 439 | 440 | #region CountdownTimer - Show countdown timer, then return 441 | public static void CountdownTimer(long seconds) 442 | { 443 | if (seconds < 0) return; 444 | 445 | // "Waiting" 446 | var consoleTitle = Console.Title; 447 | Console.Title = Constants.Name + " - " + Constants.Msg[9074].ToLower() + "..."; 448 | 449 | Console.Write(" "); 450 | Console.CursorVisible = false; 451 | var dateTime = DateTime.MinValue.AddSeconds(seconds); 452 | 453 | while (dateTime > DateTime.MinValue) 454 | { 455 | Thread.Sleep(1000); 456 | try 457 | { 458 | dateTime = dateTime.AddSeconds(-1); 459 | } 460 | catch 461 | { 462 | break; 463 | } 464 | Console.Write(dateTime.ToString("HH:mm:ss")); 465 | Console.SetCursorPosition(Math.Max(0, Console.CursorLeft - 8), Console.CursorTop); 466 | } 467 | Console.CursorVisible = true; 468 | Console.Write(" "); 469 | Console.SetCursorPosition(Math.Max(0, Console.CursorLeft - 9), Console.CursorTop); 470 | Console.Title = consoleTitle; 471 | } 472 | #endregion 473 | 474 | #region ReplaceWildcards - Replace wildcards 475 | static string ReplaceWildcards(string value) 476 | { 477 | // This method does NOT check if replacement value ​​exist 478 | 479 | var ic = RegexOptions.IgnoreCase; 480 | var duration = TimeSpan.FromSeconds(Preparer.Duration); 481 | 482 | value = Regex.Replace(value, "\\*id\\*", Waiter.Id, ic); 483 | 484 | value = Regex.Replace(value, 485 | "\\*start\\*", Preparer.Start.ToString("yyyyMMdd-HHmmss"), ic); 486 | 487 | value = Regex.Replace(value, 488 | "\\*start\\[(.+)\\]\\*", m => Preparer.Start.ToString(m.Groups[1].Value), ic); 489 | 490 | value = Regex.Replace(value, 491 | "\\*duration\\*", 492 | string.Format("{0:D3}m{1:D2}s", (int)duration.TotalMinutes, duration.Seconds), ic); 493 | 494 | value = Regex.Replace(value, "\\*resolution\\*", Preparer.Resolution.ToString(), ic); 495 | 496 | value = Regex.Replace(value, "\\*channel_id\\*", Waiter.ChannelId, ic); 497 | 498 | value = Regex.Replace(value, "\\*author\\*", Preparer.Author, ic); 499 | 500 | value = Regex.Replace(value, "\\*title\\*", Preparer.Author, ic); 501 | 502 | value = Regex.Replace(value, "\\*getnext\\*", "*getnext*", ic); 503 | 504 | value = Regex.Replace(value, "\\*output\\*", OutputDir + OutputName + Validator.OutputExt, ic); 505 | 506 | if (value.Contains("*getnext*")) 507 | { 508 | var getnextSeqStart = Preparer.StartSeq + Preparer.Duration / Preparer.SeqDuration; 509 | var getnextDuration = string 510 | .Format("{0:D3}.{1:D2}", (int)duration.TotalMinutes, duration.Seconds); 511 | var getnextOutput = Validator.OutputDir + Validator.OutputName + Validator.OutputExt; 512 | 513 | var getnext = "\"" + Assembly.GetEntryAssembly().Location + "\"" + " " + 514 | "-u=" + Waiter.Id + " " + 515 | "-s=" + "seq" + getnextSeqStart + " " + 516 | "-d=" + getnextDuration + " " + 517 | "-r=" + Preparer.Resolution + " " + 518 | "-f=" + "\"" + Validator.Ffmpeg + "\"" + " " + 519 | "-o=" + "\"" + getnextOutput + "\"" + " " + 520 | "-e=*getnext*"; 521 | 522 | if (!string.IsNullOrEmpty(Validator.Browser)) 523 | getnext += " -b=\"" + Validator.Browser + "\""; 524 | 525 | if (!string.IsNullOrEmpty(Validator.Cookie)) 526 | getnext += " -c=\"" + Validator.Cookie + "\""; 527 | 528 | if (!Validator.KeepStreamInfo) 529 | getnext += " -k=" + Validator.KeepStreamInfo; 530 | 531 | if (Validator.Log) 532 | getnext += " -l=" + Validator.Log; 533 | 534 | getnext = "\"" + getnext + "\""; 535 | 536 | value = value.Replace("*getnext*", getnext); 537 | } 538 | 539 | // Still exists '*' 540 | if (value.Replace("*getnext*", "").Contains("*")) 541 | { 542 | // "Check '-output' argument" 543 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 544 | Exit(9057); 545 | } 546 | 547 | return value; 548 | } 549 | #endregion 550 | 551 | #region ExecuteOnExit - Run 'execute on exit' command 552 | static void ExecuteOnExit(string executeOnExit) 553 | { 554 | executeOnExit = ReplaceWildcards(executeOnExit); 555 | 556 | if (Validator.Log) 557 | { 558 | var logInfo = "\nExecuteOnExit string: " + executeOnExit; 559 | Program.Log(logInfo); 560 | } 561 | 562 | try 563 | { 564 | using (var p = new Process()) 565 | { 566 | p.StartInfo.FileName = "cmd.exe"; 567 | p.StartInfo.Arguments = @"/c " + executeOnExit; 568 | p.StartInfo.UseShellExecute = false; 569 | p.Start(); 570 | } 571 | } 572 | catch (Exception e) 573 | { 574 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 575 | if (Validator.Log) Program.Log(Program.ErrInfo); 576 | } 577 | } 578 | #endregion 579 | 580 | #region DownloadString - Get data as string at the specified URL 581 | public static string DownloadString(string url, int attempts, int interval, out string msg) 582 | { 583 | // Call example: var content = DownloadString(uri, 3, 10, out string msg); 584 | // msg content: '[server_datetime]\t[message]' 585 | // msg content if server doesn't answer: '[message]' 586 | 587 | msg = string.Empty; 588 | var content = string.Empty; 589 | 590 | while (attempts-- > 0) 591 | { 592 | try 593 | { 594 | using (var wc = new WebClient()) 595 | { 596 | ServicePointManager.SecurityProtocol = 597 | SecurityProtocolType.Tls12 | 598 | SecurityProtocolType.Tls11 | 599 | SecurityProtocolType.Tls | 600 | SecurityProtocolType.Ssl3; 601 | content = wc.DownloadString(new Uri(url.Trim())); 602 | msg = wc.ResponseHeaders.Get("date") + "\tok"; 603 | } 604 | 605 | break; 606 | } 607 | catch (WebException e) 608 | { 609 | Program.ErrInfo = 610 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 611 | if (Validator.Log) Program.Log(Program.ErrInfo); 612 | 613 | if (attempts > 0) 614 | { 615 | Thread.Sleep(interval * 1000); 616 | continue; 617 | } 618 | 619 | msg = e.Message; 620 | if (e.Status == WebExceptionStatus.ProtocolError) 621 | msg = e.Response.Headers.Get("date") + "\t" + msg; 622 | content = string.Empty; 623 | } 624 | } 625 | 626 | return content; 627 | } 628 | #endregion 629 | 630 | #region Replace_InvalidChars - Replace characters that illegal in paths and filenames 631 | public static string Replace_InvalidChars(string input) 632 | { 633 | string output = input; 634 | 635 | output = string.Join("_", output.Split(Path.GetInvalidFileNameChars())); 636 | output = string.Join("_", output.Split(Path.GetInvalidPathChars())); 637 | 638 | return output; 639 | } 640 | #endregion 641 | 642 | #region Log - Save technical info to the current folder 643 | public static void Log(string value, string name = "") 644 | { 645 | // Add value to common log file: 646 | // if (Validator.Log) Program.Log(valueToSave); 647 | // To save value as a separate file: 648 | // if (Validator.Log) Program.Log(valueToSave, "fileName"); 649 | 650 | if (!Directory.Exists(LogDir)) 651 | { 652 | try 653 | { 654 | Directory.CreateDirectory(LogDir); 655 | 656 | // Firstly log program start time 657 | var logInfo = 658 | Constants.Name + " " + Constants.Version + " (" + Constants.BuildDate + ") started" + 659 | "\nTime on PC clock: " + (DateTime.Now - Program.Timer.Elapsed).ToString() + 660 | "\nServer time: " + Program.Start; 661 | Program.Log(logInfo); 662 | } 663 | catch 664 | { 665 | return; 666 | } 667 | } 668 | 669 | if (name == "") 670 | { 671 | name = Start.ToString("yyyyMMdd-HHmmss") + ".log"; 672 | } 673 | else 674 | { 675 | name = (Program.Start + Program.Timer.Elapsed) 676 | .ToString("yyyyMMdd-HHmmss-ffff") + "~~" + name + ".log"; 677 | } 678 | 679 | value = HttpUtility.UrlDecode(value); 680 | value = HttpUtility.UrlDecode(value); 681 | value = HttpUtility.UrlDecode(value); 682 | value = value.Replace(@"\u0026", "&").Trim(); 683 | 684 | try 685 | { 686 | File.AppendAllText( 687 | LogDir + name, 688 | "[" + (Program.Start + Program.Timer.Elapsed).ToString() + "]\n" + 689 | value + "\n\n\n\n\n"); 690 | } 691 | catch 692 | { 693 | return; 694 | } 695 | } 696 | #endregion 697 | 698 | #region LogClean - Hide user IP and cookie from log files 699 | public static void LogClean() 700 | { 701 | var userIp = string.Empty; 702 | var userCookies = string.Empty; 703 | 704 | try 705 | { 706 | userIp = Regex.Match( 707 | Preparer.UriAdirect, 708 | @"^.*ip=(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}).*$", 709 | RegexOptions.IgnoreCase 710 | ).Groups[1].Value; 711 | 712 | userCookies = Validator.CookieContent; 713 | userCookies = HttpUtility.UrlDecode(userCookies); 714 | userCookies = HttpUtility.UrlDecode(userCookies); 715 | userCookies = HttpUtility.UrlDecode(userCookies); 716 | } 717 | catch (Exception e) 718 | { 719 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 720 | if (Validator.Log) Program.Log(Program.ErrInfo); 721 | } 722 | 723 | foreach (var logFile in Directory.GetFiles(Program.LogDir)) 724 | { 725 | try 726 | { 727 | var content = File.ReadAllText(logFile); 728 | 729 | if (!string.IsNullOrEmpty(userIp)) 730 | content = content.Replace(userIp, "[" + userIp.Length + "_bytes]"); 731 | if (!string.IsNullOrEmpty(userCookies)) 732 | content = content.Replace(userCookies, "[" + userCookies.Length + "_bytes]"); 733 | content = content.Trim(); 734 | 735 | File.WriteAllText(logFile, content); 736 | } 737 | catch (Exception e) 738 | { 739 | Program.ErrInfo = 740 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 741 | if (Validator.Log) Program.Log(Program.ErrInfo); 742 | } 743 | } 744 | } 745 | #endregion 746 | 747 | #region Exit - Show result and exit 748 | public static void Exit(int code) 749 | { 750 | // Format of 'ErrInfo' string: 751 | // Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 752 | // Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + message; 753 | 754 | Console.Title = Constants.Name; 755 | if (Console.CursorLeft != 0) Console.WriteLine(); 756 | var msg = string.Empty; 757 | 758 | if (code == 0) 759 | { 760 | // "--> OK ({0})" 761 | msg = string.Format( 762 | Constants.Msg[9053], 763 | (Program.Start + Program.Timer.Elapsed).ToString()); 764 | Console.ForegroundColor = ConsoleColor.Green; 765 | Console.WriteLine(msg); 766 | Console.ResetColor(); 767 | } 768 | else if (code > 1000) 769 | { 770 | // "--> ERROR {0}: {1} ({2})" 771 | msg = Constants.Msg[code]; 772 | if (!string.IsNullOrEmpty(ErrInfo)) 773 | { 774 | if (int.TryParse(ErrInfo.TrimEnd('.'), out int line)) 775 | { 776 | if (line > 0) msg = msg + " (line " + line + ")"; 777 | } 778 | } 779 | msg = string.Format( 780 | Constants.Msg[9054], 781 | code, 782 | msg, 783 | (Program.Start + Program.Timer.Elapsed).ToString()); 784 | Console.ForegroundColor = ConsoleColor.Red; 785 | Console.WriteLine(msg); 786 | Console.ResetColor(); 787 | } 788 | 789 | if (Validator.Log) Program.Log(msg); 790 | 791 | if (Validator.Log) LogClean(); 792 | 793 | // Run 'execute on exit' command 794 | if (!string.IsNullOrEmpty(Saver.ExecuteOnExit)) 795 | { 796 | ExecuteOnExit(Saver.ExecuteOnExit); 797 | } 798 | else if (!string.IsNullOrEmpty(Validator.ExecuteOnExit)) 799 | { 800 | ExecuteOnExit(Validator.ExecuteOnExit); 801 | } 802 | 803 | Environment.Exit(code); 804 | } 805 | #endregion 806 | } 807 | } -------------------------------------------------------------------------------- /source/Saver.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.Net; 6 | using System.Reflection; 7 | using System.Text; 8 | using System.Text.RegularExpressions; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | 12 | namespace yrewind 13 | { 14 | // Downloading and saving the output 15 | class Saver 16 | { 17 | // Command to get the rest if FFmpeg hungs 18 | public static string ExecuteOnExit { get; private set; } 19 | 20 | // Full path of temporary file 21 | readonly string outputPathTmp = 22 | Program.OutputDir + 23 | Program.OutputName + "~" + "INCOMPLETE" + Constants.RandomString + 24 | Program.OutputExt; 25 | 26 | // Other variables 27 | Process ffmpeg; 28 | int seqNumberA; 29 | int seqNumberV; 30 | 31 | #region Common - Main method of the class 32 | public int Common() 33 | { 34 | int code; 35 | 36 | code = OutputDirCreate(out var outputDirCreated); 37 | if (code != 0) return code; 38 | 39 | if (Program.OutputExt == ".m3u" || Program.OutputExt == ".m3u8") 40 | { 41 | code = CreateM3U(); 42 | OutputDirDelete(outputDirCreated); 43 | if (code != 0) return code; 44 | 45 | if (!Program.ResultIsOK.HasValue) Program.ResultIsOK = true; 46 | } 47 | else 48 | { 49 | int ffmpegExitCode; 50 | if (Validator.Browser == default) 51 | { code = CreateMedia(outputPathTmp, out ffmpegExitCode); } 52 | else 53 | { code = FixCreateMedia(outputPathTmp, out ffmpegExitCode); } 54 | 55 | OutputDirDelete(outputDirCreated); 56 | if (code != 0) return code; 57 | 58 | var durationIsOk = DurationCheck(Preparer.Duration, outputPathTmp); 59 | 60 | if (ffmpegExitCode == 0 & durationIsOk != false) 61 | { 62 | try 63 | { 64 | File.Move(outputPathTmp, 65 | Program.OutputDir + Program.OutputName + Program.OutputExt); 66 | } 67 | catch (Exception e) 68 | { 69 | Program.ErrInfo = 70 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 71 | if (Validator.Log) Program.Log(Program.ErrInfo); 72 | } 73 | } 74 | 75 | if (ffmpegExitCode == 0 && durationIsOk == true && !Program.ResultIsOK.HasValue) 76 | { 77 | Program.ResultIsOK = true; 78 | } 79 | } 80 | 81 | return 0; 82 | } 83 | #endregion 84 | 85 | #region CreateMedia - Get video/audio with FFmpeg 86 | int CreateMedia(string outputPath, out int ffmpegExitCode) 87 | { 88 | ffmpegExitCode = 0; 89 | 90 | // "Saving" 91 | Console.Title = Constants.Name + " - " + Constants.Msg[9077].ToLower() + "..."; 92 | 93 | // Add video data if required 94 | var videoSource = string.Empty; 95 | var videoMapping = string.Empty; 96 | if (Preparer.Resolution > 0) 97 | { 98 | videoSource = "-protocol_whitelist file,http,https,tcp,tls" + " " + 99 | "-i " + Constants.UrlProxy + Constants.RandomString + "/v.m3u8" + " "; 100 | videoMapping = "-map 1" + " "; 101 | } 102 | 103 | // Add cover art if '.mp4' container was selected 104 | var coverSource = string.Empty; 105 | var coverMapping = string.Empty; 106 | if (Program.OutputExt == ".mp4") 107 | { 108 | try 109 | { 110 | var uri = Constants.UrlStreamCover.Replace("[stream_id]", Waiter.Id); 111 | var req = (HttpWebRequest)WebRequest.Create(new Uri(uri)); 112 | var res = (HttpWebResponse)req.GetResponse(); 113 | if (res.StatusCode == HttpStatusCode.OK) 114 | { 115 | coverSource = 116 | "-i \"" + 117 | Constants.UrlStreamCover.Replace("[stream_id]", Waiter.Id) + 118 | "\"" + " "; 119 | coverMapping = 120 | "-map 2 -disposition:1 default -disposition:2 attached_pic" + " "; 121 | } 122 | res.Close(); 123 | } 124 | catch (WebException e) 125 | { 126 | Program.ErrInfo = 127 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 128 | if (Validator.Log) Program.Log(Program.ErrInfo); 129 | } 130 | } 131 | 132 | // Create string for the 'Title' metadata field 133 | var metadataTitle = Preparer.Title + 134 | " || " + Preparer.Author + 135 | " || " + Constants.UrlStream.Replace("[stream_id]", Waiter.Id) + 136 | " || " + Constants.UrlChannel.Replace("[channel_id]", Waiter.ChannelId) + 137 | " || " + Preparer.Start.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss") + " UTC"; 138 | 139 | // Build FFmpeg arguments 140 | // -loglevel quiet/panic/fatal/error/warning/info/verbose/debug 141 | var arguments = 142 | "-loglevel fatal" + " " + 143 | "-stats" + " " + 144 | "-protocol_whitelist file,http,https,tcp,tls" + " " + 145 | "-i " + Constants.UrlProxy + Constants.RandomString + "/a.m3u8" + " " + 146 | videoSource + 147 | coverSource + 148 | "-map 0" + " " + 149 | videoMapping + 150 | coverMapping + 151 | "-metadata comment=\"Saved with " + Constants.Name + "\"" + " " + 152 | "-metadata title=\"" + metadataTitle + "\"" + " " + 153 | "-c copy" + " " + 154 | "\"" + outputPath + "\""; 155 | 156 | _ = Provider(); 157 | 158 | // Run FFmpeg process 159 | try 160 | { 161 | using (ffmpeg = new Process()) 162 | { 163 | ffmpeg.StartInfo.FileName = Validator.Ffmpeg; 164 | ffmpeg.StartInfo.Arguments = arguments; 165 | ffmpeg.StartInfo.UseShellExecute = false; 166 | if (Validator.Log) 167 | { 168 | var LogDirAdapted = 169 | Program.LogDir.Replace("\\", "\\\\").Replace(":", "\\:"); 170 | ffmpeg.StartInfo.EnvironmentVariables["FFREPORT"] = 171 | "file=" + LogDirAdapted + "ffmpeg.log:level=40"; 172 | Program.Log("Starting FFmpeg...\n" + 173 | ffmpeg.StartInfo.FileName + " " + ffmpeg.StartInfo.Arguments); 174 | } 175 | ffmpeg.Start(); 176 | ffmpeg.WaitForExit(); 177 | ffmpegExitCode = ffmpeg.ExitCode; 178 | if (Validator.Log) Program.Log("FFmpeg exit code: " + ffmpeg.ExitCode); 179 | } 180 | } 181 | catch (Exception e) 182 | { 183 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 184 | if (Validator.Log) Program.Log(Program.ErrInfo); 185 | 186 | // "FFmpeg not responding" 187 | return 9410; 188 | } 189 | 190 | if (!File.Exists(outputPath)) 191 | { 192 | // "Output file(s) creating error" 193 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 194 | return 9411; 195 | } 196 | 197 | return 0; 198 | } 199 | #endregion 200 | 201 | #region CreateM3U - Create M3U/M3U8 playlists 202 | int CreateM3U() 203 | { 204 | var contentA = string.Empty; 205 | var contentV = string.Empty; 206 | int seqStart = Preparer.StartSeq; 207 | int seqStop = seqStart + Preparer.Duration / Preparer.SeqDuration - 1; 208 | for (var i = seqStart; i <= seqStop; i++) 209 | { 210 | contentA += "#EXTINF:" + Preparer.SeqDuration + ",\n" + 211 | Preparer.UriAdirect + "&sq=" + i + "\n"; 212 | contentV += "#EXTINF:" + Preparer.SeqDuration + ",\n" + 213 | Preparer.UriVdirect + "&sq=" + i + "\n"; 214 | } 215 | contentA = "#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:0\n" + contentA + "#EXT-X-ENDLIST\n"; 216 | contentV = "#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:0\n" + contentV + "#EXT-X-ENDLIST\n"; 217 | 218 | var content = "#EXTM3U\n" + 219 | "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"stereo\",URI=" + 220 | Program.OutputName + "_audio" + Program.OutputExt + "\n" + 221 | "#EXT-X-STREAM-INF:AUDIO=\"stereo\"\n"; 222 | 223 | if (Preparer.Resolution > 0) 224 | { 225 | content = content + Program.OutputName + "_video" + Program.OutputExt + "\n"; 226 | } 227 | 228 | if (Validator.Log) 229 | { 230 | Program.Log(contentA, "playlist_a.m3u8"); 231 | Program.Log(contentV, "playlist_v.m3u8"); 232 | Program.Log(content, "playlist.m3u8"); 233 | } 234 | 235 | var encoding = Encoding.ASCII; 236 | if (Program.OutputExt == ".m3u8") encoding = Encoding.UTF8; 237 | 238 | try 239 | { 240 | File.AppendAllText( 241 | Program.OutputDir + Program.OutputName + Program.OutputExt, 242 | content, 243 | encoding); 244 | 245 | File.AppendAllText( 246 | Program.OutputDir + Program.OutputName + "_audio" + Program.OutputExt, 247 | contentA, 248 | encoding); 249 | 250 | if (Preparer.Resolution > 0) 251 | { 252 | File.AppendAllText( 253 | Program.OutputDir + Program.OutputName + "_video" + Program.OutputExt, 254 | contentV, 255 | encoding); 256 | } 257 | } 258 | catch (Exception e) 259 | { 260 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 261 | if (Validator.Log) Program.Log(Program.ErrInfo); 262 | 263 | // "Output file(s) creating error" 264 | return 9411; 265 | } 266 | 267 | if (!File.Exists(Program.OutputDir + Program.OutputName + Program.OutputExt) || 268 | !File.Exists(Program.OutputDir + Program.OutputName + "_audio" + Program.OutputExt)) 269 | { 270 | // "Output file(s) creating error" 271 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 272 | return 9411; 273 | } 274 | 275 | if (Preparer.Resolution > 0 && 276 | !File.Exists(Program.OutputDir + Program.OutputName + "_video" + Program.OutputExt)) 277 | { 278 | // "Output file(s) creating error" 279 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 280 | return 9411; 281 | } 282 | 283 | return 0; 284 | } 285 | #endregion 286 | 287 | #region Provider - Local proxy for providing HLS playlists 288 | async Task Provider() 289 | { 290 | var listener = new HttpListener(); 291 | listener.Prefixes.Add(Constants.UrlProxy + Constants.RandomString + "/"); 292 | listener.Start(); 293 | 294 | byte[] buffer; 295 | string content; 296 | bool realtime = false; 297 | int seqCheck = 0; 298 | int seqStart = Preparer.StartSeq; 299 | int seqStop = seqStart + Preparer.Duration / Preparer.SeqDuration; 300 | DateTime bufferedTimeOfCurSeq = Preparer.IdStart 301 | .AddSeconds(seqStart * Preparer.SeqDuration + Constants.RealTimeBuffer); 302 | int i = seqStart + 1; 303 | seqNumberA = seqStart; 304 | seqNumberV = seqStart; 305 | string lineA = "#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:[secNumber]\n#EXTINF:-1,\n" + 306 | Preparer.UriAdirect + "&sq=[secNumber]\n"; 307 | string lineV = "#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:[secNumber]\n#EXTINF:-1,\n" + 308 | Preparer.UriVdirect + "&sq=[secNumber]\n"; 309 | var seqChecks = new Dictionary>(); 310 | 311 | // Use a timer to see if FFmpeg is working normally 312 | var timer = new System.Timers.Timer(Constants.FfmpegTimeout * 1000); 313 | timer.Elapsed += HungsFfmpegHandler; 314 | 315 | while (true) 316 | { 317 | timer.Start(); 318 | 319 | var context = await listener.GetContextAsync(); 320 | var reqUri = context.Request.RawUrl; 321 | 322 | // Switch to real time if needed 323 | if (!realtime && (Program.Start + Program.Timer.Elapsed) < bufferedTimeOfCurSeq) 324 | { 325 | realtime = true; 326 | lineA = lineA.Replace("EXTINF:-1", "EXTINF:" + Preparer.SeqDuration); 327 | lineV = lineV.Replace("EXTINF:-1", "EXTINF:" + Preparer.SeqDuration); 328 | } 329 | 330 | // Check if next sequence exists and available 331 | if (seqCheck < Math.Max(seqNumberA + 1, seqNumberV + 1)) 332 | { 333 | seqCheck = Math.Max(seqNumberA + 1, seqNumberV + 1); 334 | 335 | if ((seqCheck < seqStop) && realtime) 336 | { 337 | var result = Preparer.CheckSeq(seqCheck); 338 | if (result != "200") 339 | { 340 | if (result == "403") 341 | { 342 | // The stream info may be out of date 343 | seqStop = seqCheck; 344 | if (Waiter.IdStatus != Waiter.Stream.Finished) TaskRest(seqStop); 345 | } 346 | else if (result == "404") 347 | { 348 | // The streamer may experience network outages, wait and try more 349 | Thread.Sleep(60000); 350 | if (Preparer.CheckSeq(seqCheck) != "200") seqStop = seqCheck; 351 | } 352 | else 353 | { 354 | // Unknown error 355 | seqStop = seqCheck; 356 | } 357 | } 358 | } 359 | else if ((seqCheck < seqStop) && !realtime) 360 | { 361 | // Check in advance whether the sequence [current+10] is available 362 | while ((i < (seqCheck + 10)) & (i < seqStop)) 363 | { 364 | seqChecks.Add(i, Task.Factory.StartNew(Preparer.CheckSeq, i)); 365 | i++; 366 | } 367 | 368 | var result = seqChecks[seqCheck].Result; 369 | if (result != "200") 370 | { 371 | if (result == "403") 372 | { 373 | // The stream info may be out of date 374 | seqStop = seqCheck; 375 | if (Waiter.IdStatus != Waiter.Stream.Finished) TaskRest(seqStop); 376 | } 377 | else if (result == "404") 378 | { 379 | // The end of the finished stream 380 | seqStop = seqCheck; 381 | } 382 | else 383 | { 384 | // Unknown error 385 | seqStop = seqCheck; 386 | } 387 | } 388 | } 389 | } 390 | 391 | // Handling FFmpeg requests 392 | if (reqUri.EndsWith("a.m3u8")) 393 | { 394 | content = lineA.Replace("[secNumber]", seqNumberA.ToString()); 395 | 396 | if (++seqNumberA == seqStop) content += "#EXT-X-ENDLIST\n"; 397 | 398 | if (!realtime) 399 | { 400 | bufferedTimeOfCurSeq = 401 | bufferedTimeOfCurSeq.AddSeconds(Preparer.SeqDuration); 402 | } 403 | 404 | if (Validator.Log) Program.Log("Get audio segment:\n" + content); 405 | } 406 | else if (reqUri.EndsWith("v.m3u8")) 407 | { 408 | content = lineV.Replace("[secNumber]", seqNumberV.ToString()); 409 | 410 | if (++seqNumberV == seqStop) content += "#EXT-X-ENDLIST\n"; 411 | 412 | if (Validator.Log) Program.Log("Get video segment:\n" + content); 413 | } 414 | else 415 | { 416 | // Unknown request 417 | context.Response.Close(); 418 | continue; 419 | } 420 | 421 | buffer = Encoding.ASCII.GetBytes(content); 422 | context.Response.ContentLength64 = buffer.Length; 423 | context.Response.OutputStream.Write(buffer, 0, buffer.Length); 424 | context.Response.OutputStream.Close(); 425 | context.Response.Close(); 426 | 427 | timer.Stop(); 428 | } 429 | } 430 | #endregion 431 | 432 | #region DurationCheck - Compares required duration with actual result 433 | bool? DurationCheck(int durationRequired, string path) 434 | { 435 | try 436 | { 437 | var t = Type.GetTypeFromProgID("Shell.Application"); 438 | dynamic s = Activator.CreateInstance(t); 439 | var folder = s.NameSpace(Path.GetDirectoryName(path)); 440 | var item = folder.ParseName(Path.GetFileName(path)); 441 | var dStr = folder.GetDetailsOf(item, 27).ToString(); 442 | 443 | // Use simple correction, because shell info contains the number of complete seconds 444 | // (for example, "4" for video with duration "4.997") 445 | var durationActual = 446 | TimeSpan.ParseExact(dStr, @"hh\:mm\:ss", null).TotalSeconds + 1; 447 | 448 | if (durationActual >= durationRequired) return true; 449 | else return false; 450 | } 451 | catch (Exception e) 452 | { 453 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 454 | if (Validator.Log) Program.Log(Program.ErrInfo); 455 | 456 | return null; 457 | } 458 | } 459 | #endregion 460 | 461 | #region OutputDirCreate - Create output directory if needed 462 | int OutputDirCreate(out string outputDirCreatedRoot) 463 | { 464 | outputDirCreatedRoot = string.Empty; 465 | 466 | if (Directory.Exists(Program.OutputDir)) return 0; 467 | 468 | // If output directory was created, keep the name of it, 469 | // if several nested directories was created, keep the name of the topmost one 470 | outputDirCreatedRoot = Program.OutputDir; 471 | var parent = Directory.GetParent(Program.OutputDir); 472 | while (!Directory.Exists(parent.FullName)) 473 | { 474 | outputDirCreatedRoot = parent.FullName; 475 | parent = Directory.GetParent(parent.FullName); 476 | } 477 | 478 | try 479 | { 480 | Directory.CreateDirectory(Program.OutputDir); 481 | } 482 | catch (Exception e) 483 | { 484 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 485 | if (Validator.Log) Program.Log(Program.ErrInfo); 486 | 487 | // "Output folder creating error" 488 | return 9412; 489 | } 490 | 491 | return 0; 492 | } 493 | #endregion 494 | 495 | #region OutputDirDelete - Delete created directory if empty 496 | void OutputDirDelete(string outputDirCreated) 497 | { 498 | if (!string.IsNullOrEmpty(outputDirCreated)) 499 | { 500 | try 501 | { 502 | var files = Directory 503 | .GetFiles(outputDirCreated, "*", SearchOption.AllDirectories); 504 | 505 | if (files.Length == 0) Directory.Delete(outputDirCreated, true); 506 | } 507 | catch (Exception e) 508 | { 509 | Program.ErrInfo = 510 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 511 | if (Validator.Log) Program.Log(Program.ErrInfo); 512 | } 513 | } 514 | } 515 | #endregion 516 | 517 | #region TaskRest - Create task to get the rest in case of any errors 518 | void TaskRest(int newStartSeq) 519 | { 520 | if (newStartSeq < Preparer.StartSeq) newStartSeq = Preparer.StartSeq; 521 | 522 | // The rest of requested duration 523 | var newDuration = TimeSpan.FromSeconds 524 | (Preparer.Duration - Preparer.SeqDuration * (newStartSeq - Preparer.StartSeq)); 525 | var newDurationStr = string.Format 526 | ("{0:D3}.{1:D2}", (int)newDuration.TotalMinutes, newDuration.Seconds); 527 | 528 | // New output name 529 | var newOutput = outputPathTmp.Replace(Program.OutputExt, "_2" + Program.OutputExt); 530 | 531 | ExecuteOnExit = "\"" + Assembly.GetEntryAssembly().Location + "\"" + " " + 532 | "-u=" + Waiter.Id + " " + 533 | "-s=" + "seq" + newStartSeq + " " + 534 | "-d=" + newDurationStr + " " + 535 | "-r=" + Preparer.Resolution + " " + 536 | "-f=" + "\"" + Validator.Ffmpeg + "\"" + " " + 537 | "-o=" + "\"" + newOutput + "\""; 538 | 539 | if (!string.IsNullOrEmpty(Validator.Browser)) 540 | ExecuteOnExit += " -b=\"" + Validator.Browser + "\""; 541 | 542 | if (!string.IsNullOrEmpty(Validator.Cookie)) 543 | ExecuteOnExit += " -c=\"" + Validator.Cookie + "\""; 544 | 545 | if (!Validator.KeepStreamInfo) 546 | ExecuteOnExit += " -k=" + Validator.KeepStreamInfo; 547 | 548 | if (Validator.Log) 549 | ExecuteOnExit += " -l=" + Validator.Log; 550 | 551 | ExecuteOnExit = "\"" + ExecuteOnExit + "\""; 552 | } 553 | #endregion 554 | 555 | #region HungsFfmpegHandler - If FFmpeg hangs 556 | void HungsFfmpegHandler(Object source, System.Timers.ElapsedEventArgs e) 557 | { 558 | if (ffmpeg != null) ffmpeg.Kill(); 559 | var newStartSeq = Math.Min(seqNumberA - 3, seqNumberV - 3); 560 | TaskRest(newStartSeq); 561 | 562 | // "Output file isn't completed, there will be a retry" 563 | Program.Exit(9413); 564 | } 565 | #endregion 566 | 567 | #region FixCreateMedia - Patch to fix YouTube throttling 568 | int FixCreateMedia(string outputPath, out int ffmpegExitCode) 569 | { 570 | ffmpegExitCode = 0; 571 | 572 | var uriAdirect = Regex.Replace(Preparer.UriAdirect, @"&ump=\d+", ""); 573 | var uriVdirect = Regex.Replace(Preparer.UriVdirect, @"&ump=\d+", ""); 574 | 575 | // Sometimes video direct URL isn't really direct 576 | var reallyDirect = false; 577 | try 578 | { 579 | while (!reallyDirect) 580 | { 581 | var req = (HttpWebRequest)WebRequest.Create(new Uri(uriVdirect)); 582 | var res = (HttpWebResponse)req.GetResponse(); 583 | reallyDirect = res.ContentType.Contains("video"); 584 | 585 | if (!reallyDirect) 586 | { 587 | using (Stream stream = res.GetResponseStream()) 588 | { 589 | StreamReader reader = new StreamReader(stream, Encoding.UTF8); 590 | uriVdirect = reader.ReadToEnd(); 591 | } 592 | } 593 | 594 | res.Close(); 595 | } 596 | } 597 | catch 598 | { 599 | // Ignore 600 | } 601 | 602 | // "Saving" 603 | Console.Title = Constants.Name + " - " + Constants.Msg[9077].ToLower() + "..."; 604 | 605 | var contentA = string.Empty; 606 | var contentV = string.Empty; 607 | 608 | int seqStart = Preparer.StartSeq; 609 | int seqStop = seqStart + Preparer.Duration / Preparer.SeqDuration - 1; 610 | 611 | for (var i = seqStart; i <= seqStop; i++) 612 | { 613 | contentA += uriAdirect + "&sq=" + i + "\n"; 614 | contentV += uriVdirect + "&sq=" + i + "\n"; 615 | } 616 | 617 | Console.WriteLine("downloading data..."); 618 | if (Preparer.Resolution > 0) FixDownloadM3U(contentV, outputPathTmp + ".video"); 619 | FixDownloadM3U(contentA, outputPathTmp + ".audio"); 620 | 621 | Console.WriteLine("concatenating media files..."); 622 | 623 | // Create string for the 'Title' metadata field 624 | var metadataTitle = Preparer.Title + 625 | " || " + Preparer.Author + 626 | " || " + Constants.UrlStream.Replace("[stream_id]", Waiter.Id) + 627 | " || " + Constants.UrlChannel.Replace("[channel_id]", Waiter.ChannelId) + 628 | " || " + Preparer.Start.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss") + " UTC"; 629 | 630 | // Build FFmpeg arguments 631 | // -loglevel quiet/panic/fatal/error/warning/info/verbose/debug 632 | var videoSource = ""; 633 | if (Preparer.Resolution > 0) videoSource = "-i " + outputPathTmp + ".video"; 634 | var arguments = 635 | "-loglevel fatal" + " " + 636 | "-stats" + " " + 637 | "-i " + outputPathTmp + ".audio" + " " + 638 | videoSource + " " + 639 | "-metadata comment=\"Saved with " + Constants.Name + "\"" + " " + 640 | "-metadata title=\"" + metadataTitle + "\"" + " " + 641 | "-c copy" + " " + 642 | "\"" + outputPath + "\""; 643 | 644 | // Run FFmpeg process 645 | try 646 | { 647 | using (ffmpeg = new Process()) 648 | { 649 | ffmpeg.StartInfo.FileName = Validator.Ffmpeg; 650 | ffmpeg.StartInfo.Arguments = arguments; 651 | ffmpeg.StartInfo.UseShellExecute = false; 652 | if (Validator.Log) 653 | { 654 | var LogDirAdapted = 655 | Program.LogDir.Replace("\\", "\\\\").Replace(":", "\\:"); 656 | ffmpeg.StartInfo.EnvironmentVariables["FFREPORT"] = 657 | "file=" + LogDirAdapted + "ffmpeg.log:level=40"; 658 | Program.Log("Starting FFmpeg...\n" + 659 | ffmpeg.StartInfo.FileName + " " + ffmpeg.StartInfo.Arguments); 660 | } 661 | ffmpeg.Start(); 662 | ffmpeg.WaitForExit(); 663 | ffmpegExitCode = ffmpeg.ExitCode; 664 | if (Validator.Log) Program.Log("FFmpeg exit code: " + ffmpeg.ExitCode); 665 | } 666 | } 667 | catch (Exception e) 668 | { 669 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 670 | if (Validator.Log) Program.Log(Program.ErrInfo); 671 | 672 | // "FFmpeg not responding" 673 | return 9410; 674 | } 675 | 676 | try 677 | { 678 | Thread.Sleep(100); 679 | 680 | File.Delete(outputPathTmp + ".video"); 681 | File.Delete(outputPathTmp + ".audio"); 682 | } 683 | catch 684 | { 685 | // Ignore 686 | } 687 | 688 | if (!File.Exists(outputPath)) 689 | { 690 | // "Output file(s) creating error" 691 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 692 | return 9411; 693 | } 694 | 695 | return 0; 696 | } 697 | #endregion 698 | 699 | #region FixDownloadM3U - Patch to fix YouTube throttling 700 | void FixDownloadM3U(string content, string path) 701 | { 702 | byte[] segment = default; 703 | double downloadSpeed; 704 | double downloadedBytes = default; 705 | DateTime time = DateTime.Now; 706 | int perc; 707 | var contentLength = content.Split('\n').Length - 1; 708 | var segmentCnt = 1; 709 | 710 | using (var fileStream = new FileStream(path, FileMode.Append, FileAccess.Write)) 711 | { 712 | using (var bw = new BinaryWriter(fileStream)) 713 | { 714 | foreach (var line in content.Split('\n')) 715 | { 716 | if (!line.StartsWith("http")) continue; 717 | 718 | using (var wc = new WebClient()) 719 | { 720 | segment = wc.DownloadData(line); 721 | } 722 | bw.Write(segment); 723 | bw.Flush(); 724 | fileStream.Flush(); 725 | 726 | downloadedBytes += segment.Length; 727 | downloadSpeed = downloadedBytes / (DateTime.Now - time).TotalMilliseconds / 1000 * 8; 728 | perc = segmentCnt++ * 100 / contentLength; 729 | Console.Write("\r {0}% at {1:0.000} Mbps", perc, downloadSpeed); 730 | } 731 | } 732 | } 733 | 734 | Console.WriteLine(); 735 | } 736 | #endregion 737 | } 738 | } -------------------------------------------------------------------------------- /source/Validator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Text.RegularExpressions; 7 | 8 | namespace yrewind 9 | { 10 | // Parsing and validating command line input 11 | class Validator 12 | { 13 | // Requested parameters 14 | public static string Url { get; private set; } // -url 15 | public static string Start { get; private set; } // -start 16 | public static string Duration { get; private set; } // -duration 17 | public static string Resolution { get; private set; } // -resolution 18 | public static string Ffmpeg { get; private set; } // -ffmpeg 19 | public static string Output { get; private set; } // -output 20 | public static string Browser { get; private set; } // -browser 21 | public static string Cookie { get; private set; } // -cookie 22 | public static bool KeepStreamInfo { get; private set; } // -keepstreaminfo 23 | public static bool Log { get; private set; } // -log 24 | public static string ExecuteOnExit { get; private set; } // -executeonexit 25 | 26 | // Other variables to determine 27 | public static string OutputDir { get; private set; } // folder part of '-output' 28 | public static string OutputName { get; private set; } // name part of '-output' 29 | public static string OutputExt { get; private set; } // extension part of '-output' 30 | public static string CookieContent { get; private set; } // cookie data 31 | 32 | #region ParseArgsLine - Get 'key=value' pairs from args line 33 | public int ParseArgsLine(string argsLine) 34 | { 35 | Dictionary args; 36 | 37 | // Unify quotes 38 | argsLine = argsLine.Replace("'", "\""); 39 | 40 | // Because value can contain a hyphen, it shouldn't be confused with an argument hyphen, 41 | // so replace the argument hyphen with TAB character 42 | // Also make the arguments case insensitive and replace one-character aliases 43 | var ic = RegexOptions.IgnoreCase; 44 | 45 | argsLine = Regex.Replace(argsLine, " -u=", " \turl=", ic); 46 | argsLine = Regex.Replace(argsLine, " -url=", " \turl=", ic); 47 | 48 | argsLine = Regex.Replace(argsLine, " -s=", " \tstart=", ic); 49 | argsLine = Regex.Replace(argsLine, " -start=", " \tstart=", ic); 50 | 51 | argsLine = Regex.Replace(argsLine, " -d=", " \tduration=", ic); 52 | argsLine = Regex.Replace(argsLine, " -duration=", " \tduration=", ic); 53 | 54 | argsLine = Regex.Replace(argsLine, " -r=", " \tresolution=", ic); 55 | argsLine = Regex.Replace(argsLine, " -resolution=", " \tresolution=", ic); 56 | 57 | argsLine = Regex.Replace(argsLine, " -f=", " \tffmpeg=", ic); 58 | argsLine = Regex.Replace(argsLine, " -ffmpeg=", " \tffmpeg=", ic); 59 | 60 | argsLine = Regex.Replace(argsLine, " -o=", " \toutput=", ic); 61 | argsLine = Regex.Replace(argsLine, " -output=", " \toutput=", ic); 62 | 63 | argsLine = Regex.Replace(argsLine, " -b=", " \tbrowser=", ic); 64 | argsLine = Regex.Replace(argsLine, " -browser=", " \tbrowser=", ic); 65 | 66 | argsLine = Regex.Replace(argsLine, " -c=", " \tcookie=", ic); 67 | argsLine = Regex.Replace(argsLine, " -cookie=", " \tcookie=", ic); 68 | 69 | argsLine = Regex.Replace(argsLine, " -k=", " \tkeepstreaminfo=", ic); 70 | argsLine = Regex.Replace(argsLine, " -keepstreaminfo=", " \tkeepstreaminfo=", ic); 71 | 72 | argsLine = Regex.Replace(argsLine, " -l=", " \tlog=", ic); 73 | argsLine = Regex.Replace(argsLine, " -log=", " \tlog=", ic); 74 | 75 | argsLine = Regex.Replace(argsLine, " -e=", " \texecuteonexit=", ic); 76 | argsLine = Regex.Replace(argsLine, " -executeonexit=", " \texecuteonexit=", ic); 77 | 78 | try 79 | { 80 | var r = new Regex("\\s\\\t(?\\w+)=(\"(?[^\"]+)\"|(?[^\\s\\\t]+))*"); 81 | 82 | args = r.Matches(argsLine).Cast().ToDictionary( 83 | m => m.Groups["key"].Value, 84 | m => m.Groups["value"].Value); 85 | } 86 | catch 87 | { 88 | // "Check for duplicate arguments on command line input" 89 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 90 | return 9110; 91 | } 92 | 93 | if (args.Count == 0) 94 | { 95 | // "Check command line input" 96 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 97 | return 9112; 98 | } 99 | 100 | return ParsePairs(args); 101 | } 102 | #endregion 103 | 104 | #region ParsePairs - Parse pairs 105 | int ParsePairs(Dictionary args) 106 | { 107 | int code = 0; 108 | KeepStreamInfo = true; 109 | 110 | // If argument is present in command line input 111 | foreach (var arg in args.Keys) 112 | { 113 | switch (arg) 114 | { 115 | case "url": code = Parse_url(args[arg]); break; 116 | case "start": code = Parse_start(args[arg]); break; 117 | case "duration": code = Parse_duration(args[arg]); break; 118 | case "resolution": code = Parse_resolution(args[arg]); break; 119 | case "ffmpeg": code = Parse_ffmpeg(args[arg]); break; 120 | case "output": code = Parse_output(args[arg]); break; 121 | case "browser": code = Parse_browser(args[arg]); break; 122 | case "cookie": code = Parse_cookie(args[arg]); break; 123 | case "keepstreaminfo": code = Parse_keepstreaminfo(args[arg]); break; 124 | case "log": code = Parse_log(args[arg]); break; 125 | case "executeonexit": code = Parse_executeonexit(args[arg]); break; 126 | } 127 | 128 | if (code != 0) return code; 129 | } 130 | 131 | // If argument was missing in command line input 132 | if (Url == default) 133 | { 134 | // "Required argument '-url' not found" 135 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 136 | return 9113; 137 | } 138 | if (Start == default) 139 | { 140 | Start = string.Empty; 141 | } 142 | if (Duration == default) 143 | { 144 | Duration = Constants.DurationDefault.ToString(); 145 | } 146 | if (Resolution == default) 147 | { 148 | Resolution = Constants.ResolutionDefault.ToString(); 149 | } 150 | if (Ffmpeg == default) 151 | { 152 | // Firstly try FFmpeg located in the program folder, 153 | // then user- and system environment variables 154 | Ffmpeg = AppDomain.CurrentDomain.BaseDirectory + "ffmpeg.exe"; 155 | if (!File.Exists(Ffmpeg)) 156 | { 157 | Ffmpeg = Environment 158 | .GetEnvironmentVariable("ffmpeg", EnvironmentVariableTarget.User); 159 | } 160 | if (!File.Exists(Ffmpeg)) 161 | { 162 | Ffmpeg = Environment 163 | .GetEnvironmentVariable("ffmpeg", EnvironmentVariableTarget.Machine); 164 | } 165 | if (!File.Exists(Ffmpeg)) 166 | { 167 | // "Check '-ffmpeg' argument" 168 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 169 | return 9118; 170 | } 171 | } 172 | if (Output == default) 173 | { 174 | // Use default subfolder in batch file folder 175 | Output = Directory.GetCurrentDirectory() + "\\" + Constants.OutputDirDefault + "\\"; 176 | code = Parse_output(Output); 177 | if (code != 0) return code; 178 | } 179 | if (Browser == default) 180 | { 181 | Browser = Constants.BrowserDefault; 182 | } 183 | if (Browser.ToLower() == "false") 184 | { 185 | Browser = default; 186 | } 187 | if (Cookie == default) 188 | { 189 | // Ignore 190 | } 191 | if (KeepStreamInfo == default) 192 | { 193 | // Ignore 194 | } 195 | if (Log == default) 196 | { 197 | // Ignore 198 | } 199 | if (ExecuteOnExit == default) 200 | { 201 | // Ignore 202 | } 203 | 204 | if (Validator.Log) 205 | { 206 | var logInfo = 207 | "\nUrl: " + Url + 208 | "\nStart: " + Start + 209 | "\nDuration: " + Duration + 210 | "\nResolution: " + Resolution + 211 | "\nFfmpeg: " + Ffmpeg + 212 | "\nOutput: " + Output + 213 | "\nBrowser: " + Browser + 214 | "\nCookie: " + Cookie + 215 | "\nKeepStreamInfo: " + KeepStreamInfo + 216 | "\nLog: " + Log + 217 | "\nExecuteOnExit: " + ExecuteOnExit + 218 | "\nOutputDir: " + OutputDir + 219 | "\nOutputName: " + OutputName + 220 | "\nOutputExt: " + OutputExt + 221 | "\nCookieString: " + CookieContent 222 | ; 223 | Program.Log(logInfo); 224 | } 225 | 226 | return 0; 227 | } 228 | #endregion 229 | 230 | #region Parse_url - Parse '-url' argument 231 | int Parse_url(string argValue) 232 | { 233 | // Cast '-url' to one of the following: 234 | // "https://www.youtube.com/watch?v=[streamID]" 235 | // "https://www.youtube.com/channel/[channelID]" 236 | // "https://www.youtube.com/c/[channelTitle]" 237 | // "https://www.youtube.com/user/[authorName]" 238 | 239 | argValue = Regex.Replace(argValue, @"\s", ""); 240 | argValue = argValue.Trim('"').Trim('/'); 241 | 242 | if (argValue.Length == 11) 243 | { 244 | Url = "https://www.youtube.com/watch?v=" + argValue; 245 | return 0; 246 | } 247 | else if (argValue.Length == 24 & !argValue.Contains('.')) 248 | { 249 | Url = "https://www.youtube.com/channel/" + argValue; 250 | return 0; 251 | } 252 | else if (argValue.ToLower().Contains("youtube.com/channel/".ToLower())) 253 | { 254 | Url = "https://www.youtube.com/channel/" + argValue.Split('/').Last(); 255 | return 0; 256 | } 257 | else if (argValue.ToLower().Contains("youtube.com/c/".ToLower())) 258 | { 259 | Url = "https://www.youtube.com/c/" + argValue.Split('/').Last(); 260 | return 0; 261 | } 262 | else if (argValue.ToLower().Contains("youtube.com/user/".ToLower())) 263 | { 264 | Url = "https://www.youtube.com/user/" + argValue.Split('/').Last(); 265 | return 0; 266 | } 267 | 268 | var match = Regex.Match(argValue, 269 | @"^.*(?:youtu.be\/|v\/|e\/|u\/\w+\/|embed\/|v=)([^#\&\?]*).*$" 270 | ).Groups[1].Value; 271 | 272 | if (match.Length == 11) 273 | { 274 | Url = "https://www.youtube.com/watch?v=" + match; 275 | return 0; 276 | } 277 | else 278 | { 279 | // "Check '-url' argument" 280 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 281 | return 9114; 282 | } 283 | } 284 | #endregion 285 | 286 | #region Parse_start - Parse '-start' argument 287 | int Parse_start(string argValue) 288 | { 289 | // Cast '-start' to one of the following: 290 | // 'YYYYMMDD:hhmmss' 291 | // '-[minutesNumber]' 292 | // '+[minutesNumber]' 293 | // 'seq[seqNumber]' (internal using in *getnext* mode) 294 | 295 | argValue = Regex.Replace(argValue, @"\s", ""); 296 | argValue = argValue.ToLower(); 297 | 298 | if (argValue.StartsWith("y:")) 299 | { 300 | argValue = argValue 301 | .Replace("y:", Program.Start.AddDays(-1).ToString("yyyyMMdd:")); 302 | } 303 | else if (argValue.StartsWith("t:")) 304 | { 305 | argValue = argValue 306 | .Replace("t:", Program.Start.ToString("yyyyMMdd:")); 307 | } 308 | else if ((argValue == "beginning") || (argValue == "b")) 309 | { 310 | Start = Constants.StartBeginning; 311 | return 0; 312 | } 313 | else if (Regex.IsMatch(argValue, @"^\-\d{1,3}$")) 314 | { 315 | Start = argValue; 316 | return 0; 317 | } 318 | else if (Regex.IsMatch(argValue, @"^\+\d{1,3}$")) 319 | { 320 | Start = argValue; 321 | return 0; 322 | } 323 | else if (Regex.IsMatch(argValue, @"^seq\d{1,15}$")) 324 | { 325 | Start = argValue; 326 | return 0; 327 | } 328 | 329 | if (Regex.IsMatch(argValue, @"^\d{8}:\d{4}$")) 330 | { 331 | argValue += "00"; 332 | } 333 | 334 | try 335 | { 336 | var start = DateTime.ParseExact(argValue, "yyyyMMdd:HHmmss", null); 337 | if ((start - Program.Start).TotalDays > 1) throw new Exception(); 338 | 339 | Start = start.ToString("yyyyMMdd:HHmmss"); 340 | } 341 | catch (Exception e) 342 | { 343 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 344 | if (Validator.Log) Program.Log(Program.ErrInfo); 345 | 346 | // "Check '-start' argument" 347 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 348 | return 9115; 349 | } 350 | 351 | return 0; 352 | } 353 | #endregion 354 | 355 | #region Parse_duration - Parse '-duration' argument 356 | int Parse_duration(string argValue) 357 | { 358 | argValue = Regex.Replace(argValue, @"\s", ""); 359 | 360 | if (argValue.ToLower().StartsWith("min")) 361 | { 362 | Duration = Constants.DurationMin.ToString(); 363 | return 0; 364 | } 365 | else if (argValue.ToLower().StartsWith("max")) 366 | { 367 | Duration = Constants.DurationMax.ToString(); 368 | return 0; 369 | } 370 | 371 | try 372 | { 373 | var duration = 0; 374 | 375 | if (Regex.IsMatch(argValue, @"^\d{1,3}$")) 376 | { 377 | duration = int.Parse(argValue) * 60; 378 | } 379 | else if (Regex.IsMatch(argValue, @"^\d{1,3}\.\d{1,2}$")) 380 | { 381 | var minutes = int.Parse(argValue.Split('.')[0]); 382 | var seconds = int.Parse(argValue.Split('.')[1]); 383 | if (seconds > 59) throw new Exception(); 384 | duration = minutes * 60 + seconds; 385 | } 386 | else 387 | { 388 | throw new Exception(); 389 | } 390 | 391 | Duration = duration.ToString(); 392 | } 393 | catch (Exception e) 394 | { 395 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 396 | if (Validator.Log) Program.Log(Program.ErrInfo); 397 | 398 | // "Check '-duration' argument" 399 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 400 | return 9116; 401 | } 402 | 403 | if ((int.Parse(Duration) < Constants.DurationMin) || 404 | (int.Parse(Duration) > Constants.DurationMax)) 405 | { 406 | // "Check '-duration' argument" 407 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 408 | return 9116; 409 | } 410 | 411 | return 0; 412 | } 413 | #endregion 414 | 415 | #region Parse_resolution - Parse '-resolution' argument 416 | int Parse_resolution(string argValue) 417 | { 418 | argValue = Regex.Replace(argValue, @"\s", ""); 419 | 420 | if (argValue.ToLower().StartsWith("min")) 421 | argValue = Constants.ResolutionMin.ToString(); 422 | if (argValue.ToLower().StartsWith("max")) 423 | argValue = Constants.ResolutionMax.ToString(); 424 | 425 | try 426 | { 427 | if (!Regex.IsMatch(argValue, @"^\d{1,4}$")) throw new Exception(); 428 | var resolution = int.Parse(argValue); 429 | Resolution = resolution.ToString(); 430 | } 431 | catch (Exception e) 432 | { 433 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 434 | if (Validator.Log) Program.Log(Program.ErrInfo); 435 | 436 | // "Check '-resolution' argument" 437 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 438 | return 9117; 439 | } 440 | 441 | return 0; 442 | } 443 | #endregion 444 | 445 | #region Parse_ffmpeg - Parse '-ffmpeg' argument 446 | int Parse_ffmpeg(string argValue) 447 | { 448 | Ffmpeg = argValue.TrimEnd(' '); 449 | Ffmpeg = Ffmpeg.Replace("/", "\\"); 450 | Ffmpeg = Environment.ExpandEnvironmentVariables(Ffmpeg); 451 | 452 | if (!Regex.IsMatch(Ffmpeg, @"^[A-Za-z]{1}:\\.*$")) 453 | { 454 | // Generate an absolute path relative to batch file 455 | Ffmpeg = Directory.GetCurrentDirectory() + "\\" + Ffmpeg.Trim('\\'); 456 | } 457 | 458 | try 459 | { 460 | if (File.Exists(Ffmpeg)) 461 | { 462 | return 0; 463 | } 464 | else if (Directory.Exists(Ffmpeg)) 465 | { 466 | string[] files = Directory 467 | .GetFiles(Ffmpeg, "ffmpeg.exe", SearchOption.AllDirectories); 468 | 469 | if (files.Count() > 0) 470 | { 471 | Ffmpeg = files[0]; 472 | return 0; 473 | } 474 | } 475 | } 476 | catch (Exception e) 477 | { 478 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 479 | if (Validator.Log) Program.Log(Program.ErrInfo); 480 | } 481 | 482 | // "Check '-ffmpeg' argument" 483 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 484 | return 9118; 485 | } 486 | #endregion 487 | 488 | #region Parse_output - Parse '-output' argument and split result 489 | int Parse_output(string argValue) 490 | { 491 | // Also determine 492 | // OutputDir ('x:\path\to\'), OutputName ('filename'), OutputExt ('.extension') 493 | 494 | Output = argValue.TrimEnd(' '); 495 | Output = Output.Replace("/", "\\"); 496 | Output = Environment.ExpandEnvironmentVariables(Output); 497 | 498 | OutputName = Output; 499 | OutputDir = string.Empty; 500 | OutputExt = string.Empty; 501 | 502 | // Split into folder, filename and extension parts 503 | if (OutputName.Contains("\\")) 504 | { 505 | OutputName = Output.Substring(Output.LastIndexOf("\\") + 1); 506 | OutputDir = Output.Substring(0, Output.LastIndexOf("\\") + 1); 507 | } 508 | if (OutputName.Contains(".")) 509 | { 510 | OutputExt = OutputName.Substring(OutputName.LastIndexOf(".")); 511 | OutputName = OutputName.Substring(0, OutputName.LastIndexOf(".")); 512 | } 513 | 514 | // Folder 515 | if (OutputDir.Length == 0) 516 | { 517 | OutputDir = Constants.OutputDirDefault; 518 | } 519 | if (!Regex.IsMatch(OutputDir, @"^[A-Za-z]{1}:\\.*$")) 520 | { 521 | // Generate an absolute path relative to batch file 522 | OutputDir = Directory.GetCurrentDirectory() + "\\" + OutputDir + "\\"; 523 | } 524 | 525 | // Extension 526 | if (OutputExt.Length == 0) 527 | { 528 | OutputExt = Constants.OutputExtDefault; 529 | } 530 | else 531 | { 532 | OutputExt = OutputExt.ToLower(); 533 | if (!Regex.IsMatch(OutputExt, @"^(\.[a-z0-9]{1,9})$")) 534 | { 535 | // "Check '-output' argument" 536 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 537 | return 9119; 538 | } 539 | } 540 | 541 | // Check if path values are correct 542 | try 543 | { 544 | var test = 545 | Path.GetFullPath(OutputDir.Replace('*', '_') + 546 | OutputName.Replace('*', '_') + 547 | OutputExt.Replace('*', '_')); 548 | } 549 | catch (Exception e) 550 | { 551 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 552 | if (Validator.Log) Program.Log(Program.ErrInfo); 553 | 554 | // "Check '-output' argument" 555 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 556 | return 9119; 557 | } 558 | 559 | return 0; 560 | } 561 | #endregion 562 | 563 | #region Parse_browser - Parse '-browser' argument 564 | int Parse_browser(string argValue) 565 | { 566 | Browser = argValue.TrimEnd(' '); 567 | Browser = Browser.Replace("/", "\\"); 568 | Browser = Environment.ExpandEnvironmentVariables(Browser); 569 | 570 | if (Browser.EndsWith(".exe", true, null) && File.Exists(Browser)) 571 | { 572 | return 0; 573 | } 574 | else if (!Browser.Contains("\\")) 575 | { 576 | return 0; 577 | } 578 | else 579 | { 580 | // "Check '-browser' argument" 581 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 582 | return 9120; 583 | } 584 | } 585 | #endregion 586 | 587 | #region Parse_cookie - Parse '-cookie' argument 588 | int Parse_cookie(string argValue) 589 | { 590 | // Also determine CookieString 591 | 592 | Cookie = argValue.TrimEnd(' '); 593 | Cookie = Cookie.Replace("/", "\\"); 594 | Cookie = Environment.ExpandEnvironmentVariables(Cookie); 595 | 596 | // Generate an absolute path relative to batch file 597 | if (!Regex.IsMatch(Cookie, @"^[A-Za-z]{1}:\\.*$")) 598 | { 599 | Cookie = Directory.GetCurrentDirectory() + "\\" + Cookie.Trim('\\'); 600 | } 601 | 602 | if (!File.Exists(Cookie)) 603 | { 604 | // "Check '-cookie' argument" 605 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 606 | return 9121; 607 | } 608 | 609 | try 610 | { 611 | long cookieFileLength = new FileInfo(Cookie).Length; 612 | if (cookieFileLength > 1000000) throw new Exception(); 613 | 614 | var cookieFileContent = File.ReadLines(Cookie); 615 | if (cookieFileContent.Count(line => !string.IsNullOrWhiteSpace(line)) == 1) 616 | { 617 | // File contains one non-empty line 618 | CookieContent = cookieFileContent.First().Trim().Trim('\'').Trim('\"'); 619 | } 620 | else 621 | { 622 | // Netscape cookie format 623 | foreach (var line in cookieFileContent) 624 | { 625 | if (line.Contains("\t")) 626 | { 627 | CookieContent += line.Split('\t')[5] + "=" + line.Split('\t')[6] + "; "; 628 | } 629 | } 630 | CookieContent = CookieContent.Substring(0, CookieContent.Length - 2); 631 | } 632 | } 633 | catch (Exception e) 634 | { 635 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 636 | if (Validator.Log) Program.Log(Program.ErrInfo); 637 | 638 | // "Cannot read cookie file" 639 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 640 | return 9111; 641 | } 642 | 643 | return 0; 644 | } 645 | #endregion 646 | 647 | #region Parse_keepstreaminfo - Parse '-Parse_keepstreaminfo' argument 648 | int Parse_keepstreaminfo(string argValue) 649 | { 650 | argValue = Regex.Replace(argValue, @"\s", ""); 651 | argValue = argValue.ToLower(); 652 | 653 | if (argValue == "false") 654 | { 655 | KeepStreamInfo = false; 656 | } 657 | 658 | return 0; 659 | } 660 | #endregion 661 | 662 | #region Parse_log - Parse '-log' argument 663 | int Parse_log(string argValue) 664 | { 665 | argValue = Regex.Replace(argValue, @"\s", ""); 666 | argValue = argValue.ToLower(); 667 | 668 | if (argValue == "true") 669 | { 670 | Log = true; 671 | } 672 | 673 | return 0; 674 | } 675 | #endregion 676 | 677 | #region Parse_executeonexit - Parse '-executeonexit' argument 678 | int Parse_executeonexit(string argValue) 679 | { 680 | ExecuteOnExit = argValue.TrimEnd(' '); 681 | ExecuteOnExit = ExecuteOnExit.Replace("/", "\\"); 682 | ExecuteOnExit = Environment.ExpandEnvironmentVariables(ExecuteOnExit); 683 | 684 | return 0; 685 | } 686 | #endregion 687 | } 688 | } -------------------------------------------------------------------------------- /source/Waiter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Net; 6 | using System.Runtime.Serialization.Json; 7 | using System.Text; 8 | using System.Text.RegularExpressions; 9 | using System.Threading; 10 | using System.Web; 11 | using System.Xml; 12 | using System.Xml.Linq; 13 | using System.Xml.XPath; 14 | 15 | namespace yrewind 16 | { 17 | // Waiting for stream and get its technical information 18 | class Waiter 19 | { 20 | // Stream ID 21 | public static string Id { get; private set; } 22 | 23 | // Stream status when first checked (ongoing, upcoming, finished) 24 | public enum Stream { Unknown, Upcoming, Ongoing, Finished }; 25 | public static Stream IdStatus { get; private set; } 26 | 27 | // Channel ID 28 | public static string ChannelId { get; private set; } 29 | 30 | // XML-wrapped JSON created from stream HTML page, and its text representation 31 | public static XElement JsonHtml { get; private set; } 32 | public static string JsonHtmlStr { get; private set; } 33 | 34 | // Direct URLs of current sequences 35 | public static string UriAdirect { get; private set; } 36 | public static string UriVdirect { get; private set; } 37 | 38 | #region Common - Main method of the class 39 | public int Common() 40 | { 41 | int code; 42 | Id = string.Empty; 43 | ChannelId = string.Empty; 44 | UriAdirect = string.Empty; 45 | UriVdirect = string.Empty; 46 | JsonHtmlStr = string.Empty; 47 | 48 | // Get stream ID 49 | if (Validator.Url.StartsWith("https://www.youtube.com/watch?v=")) 50 | { 51 | Id = Validator.Url.Replace("https://www.youtube.com/watch?v=", ""); 52 | } 53 | else 54 | { 55 | code = GetChannelId(Validator.Url); 56 | if (code != 0) return code; 57 | 58 | code = WaitOnChannel(); 59 | if (code != 0) return code; 60 | } 61 | 62 | // Prepare cache 63 | var cache = new Cache(); 64 | 65 | if (!Validator.KeepStreamInfo) cache.Delete(); 66 | 67 | cache.Read(Id, out string idStatus, out string channelId, 68 | out string uriAdirect, out string uriVdirect, out string jsonHtmlStr); 69 | 70 | if (Enum.TryParse(idStatus, out Stream idStatusParsed)) IdStatus = idStatusParsed; 71 | ChannelId = channelId; 72 | UriAdirect = uriAdirect; 73 | UriVdirect = uriVdirect; 74 | JsonHtml = GetHtmlJson_Convert(jsonHtmlStr); 75 | 76 | if (string.IsNullOrEmpty(Validator.Browser)) 77 | { 78 | UriAdirect = string.Empty; 79 | UriVdirect = string.Empty; 80 | } 81 | else if (UriAdirect == string.Empty || UriVdirect == string.Empty) 82 | { 83 | code = GetBrowserNetlog(out string netlog); 84 | if (code == 0) GetBrowserNetlog_Convert(netlog); 85 | } 86 | 87 | if (IdStatus == Stream.Unknown || ChannelId == string.Empty || JsonHtml == default) 88 | { 89 | code = WaitOnId(); 90 | if (code != 0) return code; 91 | } 92 | 93 | // If stream status still unknown 94 | if (IdStatus == Stream.Unknown) IdStatus = Stream.Ongoing; 95 | 96 | if (Validator.Log) 97 | { 98 | var logInfo = 99 | "\nId: " + Id + 100 | "\nIdStatus: " + IdStatus + 101 | "\nChannelId: " + ChannelId + 102 | "\nUriAdirect: " + UriAdirect + 103 | "\nUriVdirect: " + UriVdirect + 104 | "\nJsonHtmlStr.Length: " + JsonHtmlStr.Length; 105 | Program.Log(logInfo); 106 | } 107 | 108 | if (Validator.KeepStreamInfo) 109 | { 110 | cache.Write(Id, IdStatus.ToString(), ChannelId, UriAdirect, UriVdirect, JsonHtmlStr); 111 | } 112 | 113 | if (Id == string.Empty || ChannelId == string.Empty) 114 | { 115 | // "Cannot get live stream information" 116 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 117 | return 9210; 118 | } 119 | 120 | return 0; 121 | } 122 | #endregion 123 | 124 | #region GetChannelId - Determine channel ID (by checking if channel exists) 125 | int GetChannelId(string url) 126 | { 127 | // Input variants: 128 | // "https://www.youtube.com/channel/[channelId]" 129 | // "https://www.youtube.com/c/[channelTitle]" 130 | // "https://www.youtube.com/user/[authorName]" 131 | 132 | if (url.StartsWith("https://www.youtube.com/channel/")) 133 | { 134 | ChannelId = url.Replace("https://www.youtube.com/channel/", ""); 135 | 136 | try 137 | { 138 | using (var wc = new WebClient()) 139 | { 140 | var uri = Constants.UrlChannelCheck.Replace("[channel_id]", ChannelId); 141 | wc.DownloadString(new Uri(uri)); 142 | } 143 | } 144 | catch (WebException e) 145 | { 146 | Program.ErrInfo = 147 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 148 | if (Validator.Log) Program.Log(Program.ErrInfo); 149 | 150 | // "Cannot get channel information" 151 | return 9212; 152 | } 153 | } 154 | else 155 | { 156 | var content = string.Empty; 157 | 158 | try 159 | { 160 | using (var wc = new WebClient()) 161 | { 162 | content = wc.DownloadString(new Uri(url)); 163 | } 164 | } 165 | catch (WebException e) 166 | { 167 | Program.ErrInfo = 168 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 169 | if (Validator.Log) Program.Log(Program.ErrInfo); 170 | 171 | // "Cannot get channel information. If URL contains '%', is it escaped?" 172 | return 9213; 173 | } 174 | 175 | ChannelId = Regex.Match( 176 | content, 177 | ".+?(https://www.youtube.com/channel/)(.{24}).+", 178 | RegexOptions.Singleline | RegexOptions.IgnoreCase 179 | ).Groups[2].Value; 180 | if (ChannelId.Length != 24) 181 | { 182 | // "Cannot get channel information" 183 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 184 | return 9212; 185 | } 186 | } 187 | 188 | return 0; 189 | } 190 | #endregion 191 | 192 | #region WaitOnChannel - Wait for a new stream, determine stream ID when it starts 193 | int WaitOnChannel() 194 | { 195 | // Wait for a new stream on the channel ignoring existing streams: 196 | // search for strings like 'ytimg.com/vi/[streamID]/*_live.jpg' at each iteration 197 | // in the html of the page 'https://www.youtube.com/channel/[channel_id]/streams' 198 | var streamsOnChannel = Enumerable.Empty(); 199 | var streamsOnChannelPrev = Enumerable.Empty(); 200 | var firstPass = true; 201 | var r = new Regex(@"ytimg\.com\/vi\/(.{11})\/\w+_live\.jpg"); 202 | var uriStreams = Constants.UrlChannel.Replace("[channel_id]", ChannelId) + "/streams"; 203 | 204 | IdStatus = Stream.Upcoming; 205 | 206 | while (true) 207 | { 208 | var content = string.Empty; 209 | 210 | try 211 | { 212 | using (var wc = new WebClient()) 213 | { 214 | content = wc.DownloadString(new Uri(uriStreams)); 215 | } 216 | } 217 | catch (WebException e) 218 | { 219 | Program.ErrInfo = 220 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 221 | if (Validator.Log) Program.Log(Program.ErrInfo); 222 | } 223 | 224 | streamsOnChannelPrev = streamsOnChannel; 225 | streamsOnChannel = r.Matches(content).OfType() 226 | .Select(i => i.Groups[1].Value).Distinct(); 227 | 228 | if (firstPass) 229 | { 230 | firstPass = false; 231 | continue; // On the first pass, only read existing streams 232 | } 233 | 234 | if (streamsOnChannel.Count() > streamsOnChannelPrev.Count()) 235 | { 236 | try 237 | { 238 | Id = streamsOnChannel.Except(streamsOnChannelPrev).First(); 239 | return 0; 240 | } 241 | catch (Exception e) 242 | { 243 | Program.ErrInfo = 244 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 245 | if (Validator.Log) Program.Log(Program.ErrInfo); 246 | } 247 | } 248 | 249 | Program.CountdownTimer(180); 250 | } 251 | } 252 | #endregion 253 | 254 | #region WaitOnId - Determine if it's downloadable stream, wait if upcoming 255 | int WaitOnId() 256 | { 257 | string streamStatus; 258 | var code = 0; 259 | 260 | while (true) 261 | { 262 | // Try several times in case of incomplete HTML or incorrect JSON data 263 | var attempt = 10; 264 | while (attempt-- > 0) 265 | { 266 | code = GetHtmlJson(); 267 | if (code != 0) Thread.Sleep(5000); 268 | else break; 269 | } 270 | if (code != 0) return code; 271 | 272 | // This ID unavailable or it's the regular video 273 | try 274 | { 275 | if (JsonHtml.XPathSelectElement("//isLiveContent").Value == "false") 276 | { 277 | // "It's not a live stream" 278 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 279 | return 9215; 280 | } 281 | } 282 | catch (Exception e) 283 | { 284 | Program.ErrInfo = 285 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 286 | if (Validator.Log) Program.Log(Program.ErrInfo); 287 | 288 | // "Video unavailable" 289 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 290 | return 9214; 291 | } 292 | 293 | // Copyrighted live stream 294 | if (JsonHtml.XPathSelectElement("//signatureCipher") != null) 295 | { 296 | // "Saving copyrighted live streams is blocked" 297 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 298 | return 9216; 299 | } 300 | 301 | // Ongoing live stream 302 | if (JsonHtml.XPathSelectElement("//isLiveNow") == null) 303 | { 304 | // "Cannot get live stream information" 305 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 306 | return 9210; 307 | } 308 | if (JsonHtml.XPathSelectElement("//isLiveNow").Value == "true") 309 | { 310 | if (JsonHtml.XPathSelectElement("//targetDurationSec") != null) 311 | { 312 | if (ChannelId == string.Empty) 313 | { 314 | try 315 | { 316 | ChannelId = JsonHtml.XPathSelectElement("//channelId").Value; 317 | } 318 | catch (Exception e) 319 | { 320 | Program.ErrInfo = 321 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 322 | if (Validator.Log) Program.Log(Program.ErrInfo); 323 | 324 | // "Cannot get live stream information" 325 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 326 | return 9210; 327 | } 328 | } 329 | 330 | if (IdStatus == 0) IdStatus = Stream.Ongoing; 331 | 332 | return 0; 333 | } 334 | else 335 | { 336 | // "Seems to be a restricted live stream, try '-b' or '-c' option" 337 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 338 | return 9217; 339 | } 340 | } 341 | 342 | // Get status 343 | try 344 | { 345 | streamStatus = JsonHtml.XPathSelectElement("//status").Value; 346 | } 347 | catch (Exception e) 348 | { 349 | Program.ErrInfo = 350 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 351 | if (Validator.Log) Program.Log(Program.ErrInfo); 352 | 353 | // "Cannot get live stream information" 354 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 355 | return 9210; 356 | } 357 | 358 | // Upcoming live stream 359 | if (JsonHtml.XPathSelectElement("//isUpcoming") != null) 360 | { 361 | if (streamStatus == "LOGIN_REQUIRED") 362 | { 363 | // "Seems to be a restricted live stream, try '-b' or '-c' option" 364 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 365 | return 9217; 366 | } 367 | else 368 | { 369 | if (IdStatus == 0) IdStatus = Stream.Upcoming; 370 | 371 | // Don't wait if the requested starting point is in the past 372 | if (Regex.IsMatch(Validator.Start, @"^\d{8}:\d{6}$")) 373 | { 374 | var start = DateTime 375 | .ParseExact(Validator.Start, "yyyyMMdd:HHmmss", null); 376 | if (start < Program.Start) 377 | { 378 | // "For an upcoming stream, start point cannot be in the past" 379 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 380 | return 9218; 381 | } 382 | } 383 | else 384 | { 385 | // Wait 386 | Program.CountdownTimer(180); 387 | continue; 388 | } 389 | } 390 | } 391 | 392 | // Finished live stream 393 | if (JsonHtml.XPathSelectElement("//targetDurationSec") != null) 394 | { 395 | if (ChannelId == string.Empty) 396 | { 397 | try 398 | { 399 | ChannelId = JsonHtml.XPathSelectElement("//channelId").Value; 400 | } 401 | catch (Exception e) 402 | { 403 | Program.ErrInfo = 404 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 405 | if (Validator.Log) Program.Log(Program.ErrInfo); 406 | 407 | // "Cannot get live stream information" 408 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 409 | return 9210; 410 | } 411 | } 412 | 413 | if (IdStatus == 0) IdStatus = Stream.Finished; 414 | 415 | return 0; 416 | } 417 | else 418 | { 419 | if (streamStatus == "OK") 420 | { 421 | // "Unavailable, the live stream ended too long ago" 422 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 423 | return 9211; 424 | } 425 | else 426 | { 427 | // "Seems to be a restricted live stream, try '-b' or '-c' option" 428 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 429 | return 9217; 430 | } 431 | } 432 | } 433 | } 434 | #endregion 435 | 436 | #region GetBrowserNetlog - Get content of browser network log 437 | int GetBrowserNetlog(out string netlog) 438 | { 439 | netlog = string.Empty; 440 | var browser = Validator.Browser; 441 | var args = Constants.UrlStream.Replace("[stream_id]", Waiter.Id) + 442 | " --headless --disable-extensions --disable-gpu --mute-audio --no-sandbox" + 443 | " --log-net-log=\"" + Constants.PathNetlog + "\""; 444 | int attempt = 5; 445 | 446 | while (attempt-- > 0) 447 | { 448 | try 449 | { 450 | using (var p = new Process()) 451 | { 452 | p.StartInfo.FileName = browser; 453 | p.StartInfo.Arguments = args; 454 | p.Start(); 455 | p.WaitForExit(); 456 | } 457 | } 458 | catch (Exception e) 459 | { 460 | Program.ErrInfo = 461 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 462 | if (Validator.Log) Program.Log(Program.ErrInfo); 463 | 464 | continue; 465 | } 466 | 467 | try 468 | { 469 | netlog = File.ReadAllText(Constants.PathNetlog); 470 | File.Delete(Constants.PathNetlog); 471 | } 472 | catch (Exception e) 473 | { 474 | Program.ErrInfo = 475 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 476 | if (Validator.Log) Program.Log(Program.ErrInfo); 477 | 478 | continue; 479 | } 480 | 481 | if (Validator.Log) Program.Log(netlog, "netlog_" + attempt); 482 | 483 | if (netlog.Contains("&sq=") & 484 | netlog.Contains("mime=video") & 485 | netlog.Contains("mime=audio")) return 0; 486 | 487 | Thread.Sleep(5000); 488 | } 489 | 490 | // "Cannot get live stream information with browser" 491 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + ""; 492 | return 9220; 493 | } 494 | #endregion 495 | 496 | #region GetBrowserNetlog_Convert - Find direct URLs in browser netlog 497 | void GetBrowserNetlog_Convert(string netlog) 498 | { 499 | foreach (var line in netlog.Split('\n')) 500 | { 501 | if (UriAdirect == string.Empty) 502 | { 503 | UriAdirect = Regex.Match( 504 | line, 505 | "^.*?(https[^\"]+mime=audio[^\"]+).*$", 506 | RegexOptions.IgnoreCase).Groups[1].Value; 507 | UriAdirect = HttpUtility.UrlDecode(UriAdirect); 508 | UriAdirect = HttpUtility.UrlDecode(UriAdirect); 509 | UriAdirect = HttpUtility.UrlDecode(UriAdirect); 510 | } 511 | 512 | if (UriVdirect == string.Empty) 513 | { 514 | UriVdirect = Regex.Match( 515 | line, 516 | "^.*?(https[^\"]+mime=video[^\"]+).*$", 517 | RegexOptions.IgnoreCase).Groups[1].Value; 518 | UriVdirect = HttpUtility.UrlDecode(UriVdirect); 519 | UriVdirect = HttpUtility.UrlDecode(UriVdirect); 520 | UriVdirect = HttpUtility.UrlDecode(UriVdirect); 521 | } 522 | } 523 | } 524 | #endregion 525 | 526 | #region GetHtmlJson - Get HTML page and JSON text from it 527 | int GetHtmlJson() 528 | { 529 | var content = string.Empty; 530 | 531 | // Download HTML 532 | while (true) 533 | { 534 | try 535 | { 536 | var uri = Constants.UrlStream.Replace("[stream_id]", Id); 537 | 538 | using (var wc = new WebClient()) 539 | { 540 | wc.Encoding = Encoding.UTF8; 541 | 542 | // Add cookie if it was specified 543 | if (!string.IsNullOrEmpty(Validator.CookieContent)) 544 | { 545 | wc.Headers.Add("Cookie", Validator.CookieContent); 546 | } 547 | 548 | content = wc.DownloadString(new Uri(uri)); 549 | } 550 | 551 | break; 552 | } 553 | catch (WebException e) 554 | { 555 | Program.ErrInfo = 556 | new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 557 | if (Validator.Log) Program.Log(Program.ErrInfo); 558 | 559 | if (e.Status == WebExceptionStatus.ProtocolError) 560 | { 561 | // "Cannot get live stream information" 562 | return 9210; 563 | } 564 | else 565 | { 566 | // Do not throw an error if the Internet is lost for a while 567 | Thread.Sleep(60000); 568 | } 569 | } 570 | } 571 | 572 | if (Validator.Log) Program.Log(content, "html_full"); 573 | 574 | // Find JSON part in HTML 575 | content = content.Split 576 | (new string[] { "var ytInitialPlayerResponse = " }, StringSplitOptions.None)[1]; 577 | var c = 0; 578 | var i = 0; 579 | do 580 | { 581 | if (content[i] == '{') c++; 582 | else if (content[i] == '}') c--; 583 | i++; 584 | } 585 | while (c != 0 & i < content.Length); 586 | JsonHtmlStr = content.Substring(0, i); 587 | 588 | JsonHtml = GetHtmlJson_Convert(JsonHtmlStr); 589 | if (Validator.Log) Program.Log(JsonHtmlStr, "html_json"); 590 | 591 | return 0; 592 | } 593 | #endregion 594 | 595 | #region GetHtmlJson_Convert - Convert JSON text to object 596 | XElement GetHtmlJson_Convert(string jsonHtmlStr) 597 | { 598 | try 599 | { 600 | var tmp1 = Encoding.UTF8.GetBytes(HttpUtility.UrlDecode(jsonHtmlStr)); 601 | var tmp2 = JsonReaderWriterFactory 602 | .CreateJsonReader(tmp1, new XmlDictionaryReaderQuotas()); 603 | JsonHtml = XElement.Load(tmp2); 604 | JsonHtmlStr = jsonHtmlStr; 605 | } 606 | catch (Exception e) 607 | { 608 | Program.ErrInfo = new StackFrame(0, true).GetFileLineNumber() + " - " + e.Message; 609 | if (Validator.Log) Program.Log(Program.ErrInfo); 610 | JsonHtml = default; 611 | } 612 | 613 | return JsonHtml; 614 | } 615 | #endregion 616 | } 617 | } --------------------------------------------------------------------------------