mirror of
https://github.com/crunchy-labs/crunchy-cli.git
synced 2026-01-21 12:12:00 -06:00
Initial commit
This commit is contained in:
commit
5f1d811c66
23 changed files with 3612 additions and 0 deletions
224
format.go
Normal file
224
format.go
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
package crunchyroll
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"fmt"
|
||||
"github.com/grafov/m3u8"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
const (
|
||||
EPISODE FormatType = "episodes"
|
||||
MOVIE = "movies"
|
||||
)
|
||||
|
||||
type FormatType string
|
||||
type Format struct {
|
||||
crunchy *Crunchyroll
|
||||
|
||||
ID string
|
||||
// FormatType represents if the format parent is an episode or a movie
|
||||
FormatType FormatType
|
||||
Video *m3u8.Variant
|
||||
AudioLocale LOCALE
|
||||
Hardsub LOCALE
|
||||
Subtitles []*Subtitle
|
||||
}
|
||||
|
||||
// Download downloads the format to the given output file (as .ts file).
|
||||
// See Format.DownloadSegments for more information
|
||||
func (f *Format) Download(output *os.File, onSegmentDownload func(segment *m3u8.MediaSegment, current, total int, file *os.File, err error) error) error {
|
||||
downloadDir, err := os.MkdirTemp("", "crunchy_")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(downloadDir)
|
||||
|
||||
if err := f.DownloadSegments(downloadDir, 4, onSegmentDownload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return f.mergeSegments(downloadDir, output)
|
||||
}
|
||||
|
||||
// DownloadSegments downloads every mpeg transport stream segment to a given directory (more information below).
|
||||
// After every segment download onSegmentDownload will be called with:
|
||||
// the downloaded segment, the current position, the total size of segments to download, the file where the segment content was written to an error (if occurred).
|
||||
// The filename is always <number of downloaded segment>.ts
|
||||
//
|
||||
// Short explanation:
|
||||
// The actual crunchyroll video is split up in multiple segments (or video files) which have to be downloaded and merged after to generate a single video file.
|
||||
// And this function just downloads each of this segment into the given directory.
|
||||
// See https://en.wikipedia.org/wiki/MPEG_transport_stream for more information
|
||||
func (f *Format) DownloadSegments(outputDir string, goroutines int, onSegmentDownload func(segment *m3u8.MediaSegment, current, total int, file *os.File, err error) error) error {
|
||||
resp, err := f.crunchy.Client.Get(f.Video.URI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// reads the m3u8 file
|
||||
playlist, _, err := m3u8.DecodeFrom(resp.Body, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// extracts the segments from the playlist
|
||||
var segments []*m3u8.MediaSegment
|
||||
for _, segment := range playlist.(*m3u8.MediaPlaylist).Segments {
|
||||
// some segments are nil, so they have to be filtered out
|
||||
if segment != nil {
|
||||
segments = append(segments, segment)
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
chunkSize := len(segments) / goroutines
|
||||
|
||||
// when a afterDownload call returns an error, this channel will be set to true and stop all goroutines
|
||||
quit := make(chan bool)
|
||||
|
||||
// receives the decrypt block and iv from the first segment.
|
||||
// in my tests, only the first segment has specified this data, so the decryption data from this first segments will be used in every other segment too
|
||||
block, iv, err := f.getCrypt(segments[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var current int32
|
||||
for i := 0; i < len(segments); i += chunkSize {
|
||||
wg.Add(1)
|
||||
end := i + chunkSize
|
||||
if end > len(segments) {
|
||||
end = len(segments)
|
||||
}
|
||||
i := i
|
||||
go func() {
|
||||
for j, segment := range segments[i:end] {
|
||||
select {
|
||||
case <-quit:
|
||||
break
|
||||
default:
|
||||
var file *os.File
|
||||
file, err = f.downloadSegment(segment, path.Join(outputDir, fmt.Sprintf("%d.ts", i+j)), block, iv)
|
||||
if err != nil {
|
||||
quit <- true
|
||||
break
|
||||
}
|
||||
if onSegmentDownload != nil {
|
||||
if err = onSegmentDownload(segment, int(atomic.AddInt32(¤t, 1)), len(segments), file, err); err != nil {
|
||||
quit <- true
|
||||
file.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
file.Close()
|
||||
}
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
select {
|
||||
case <-quit:
|
||||
return err
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// getCrypt extracts the key and iv of a m3u8 segment and converts it into a cipher.Block block and a iv byte sequence
|
||||
func (f *Format) getCrypt(segment *m3u8.MediaSegment) (block cipher.Block, iv []byte, err error) {
|
||||
var resp *http.Response
|
||||
|
||||
resp, err = f.crunchy.Client.Get(segment.Key.URI)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
key, err := ioutil.ReadAll(resp.Body)
|
||||
|
||||
block, err = aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
iv = []byte(segment.Key.IV)
|
||||
if len(iv) == 0 {
|
||||
iv = key
|
||||
}
|
||||
|
||||
return block, iv, nil
|
||||
}
|
||||
|
||||
// downloadSegment downloads a segments, decrypts it and names it after the given index
|
||||
func (f *Format) downloadSegment(segment *m3u8.MediaSegment, filename string, block cipher.Block, iv []byte) (*os.File, error) {
|
||||
// every segment is aes-128 encrypted and has to be decrypted when downloaded
|
||||
content, err := decryptSegment(f.crunchy.Client, segment, block, iv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// some mpeg stream things. see the link beneath for more information
|
||||
// https://github.com/oopsguy/m3u8/blob/4150e93ec8f4f8718875a02973f5d792648ecb97/dl/dowloader.go#L135
|
||||
syncByte := uint8(71) //0x47
|
||||
for k := 0; k < len(content); k++ {
|
||||
if content[k] == syncByte {
|
||||
content = content[k:]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err = file.Write(content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// mergeSegments reads every file in tempPath and write their content to output
|
||||
func (f *Format) mergeSegments(tempPath string, output *os.File) error {
|
||||
dir, err := os.ReadDir(tempPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writer := bufio.NewWriter(output)
|
||||
defer writer.Flush()
|
||||
|
||||
// sort the directory files after their numeric names
|
||||
sort.Slice(dir, func(i, j int) bool {
|
||||
iNum, err := strconv.Atoi(strings.Split(dir[i].Name(), ".")[0])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
jNum, err := strconv.Atoi(strings.Split(dir[j].Name(), ".")[0])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return iNum < jNum
|
||||
})
|
||||
|
||||
for _, file := range dir {
|
||||
bodyAsBytes, err := ioutil.ReadFile(path.Join(tempPath, file.Name()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = writer.Write(bodyAsBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue