├── Dockerfile ├── README.md ├── cmd └── proxy │ └── main.go ├── go.mod ├── go.sum └── internal ├── molotov └── molotov.go └── widevine └── todaydrm.go /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine AS builder 2 | WORKDIR /proxy 3 | COPY . . 4 | RUN GOOS=linux go build -o proxy ./cmd/proxy 5 | 6 | FROM alpine AS proxy 7 | WORKDIR / 8 | COPY --from=builder /proxy/proxy proxy 9 | ENTRYPOINT ["/proxy"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Molotov.tv DRM Proxy 2 | 3 | This server offer ability to proxify [Molotov.tv](https://www.molotov.tv/) and its DRM ([DRM Today](https://castlabs.com/drmtoday/)). 4 | Valid account is required. 5 | 6 | ## :warning: Disclaimer 7 | 8 | This project aims to demonstrate the feasibility (PoC), sharing copyrighted content without the necessary authorization is prohibited, use at your own risk. 9 | 10 | ## Build & Run 11 | 12 | ```shell script 13 | docker build -t motolov-proxy . 14 | docker run --name motolov-proxy -p 80:80 -e PORT=80 -e USER={MOLOTOV_USER} -e PASS={MOLOTOV_PASS} motolov-proxy 15 | ``` 16 | 17 | :warning: Some geo-restriction are in place, the server IP must be in correct country to watch content. 18 | 19 | ## Profit 20 | 21 | You need episode ID in order to request the correct video, by example Rick & Morty S04E01 is `3997154`. 22 | 23 | Manifest: http://localhost/cdn/3997154.mpd 24 | DRM: http://localhost/drm/3997154 25 | 26 | You can test here: https://www.jwplayer.com/developers/stream-tester/ 27 | (Use `Widevine` as DRM) 28 | 29 | ### Infos 30 | 31 | 1. I have removed subtitles from manifest in order to be compatible with JWPlayer. 32 | 2. Your content MUST be distribued with HTTPS, you can use Let's Encrypt or CloudFlare for this. -------------------------------------------------------------------------------- /cmd/proxy/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | "os" 8 | "path" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/gorilla/mux" 14 | "github.com/zencoder/go-dash/mpd" 15 | 16 | "widevine-proxy/internal/molotov" 17 | "widevine-proxy/internal/widevine" 18 | ) 19 | 20 | var ( 21 | molotovClient *molotov.Client 22 | ) 23 | 24 | func main() { 25 | var err error 26 | 27 | port, _ := os.LookupEnv("PORT") 28 | user, _ := os.LookupEnv("USER") 29 | pass, _ := os.LookupEnv("PASS") 30 | 31 | ctx := context.Background() 32 | 33 | molotovClient, err = molotov.New(ctx, user, pass) 34 | 35 | if err != nil { 36 | log.Fatal(err) 37 | } 38 | 39 | connected, err := molotovClient.Auth() 40 | 41 | if err != nil || !connected { 42 | log.Fatal(err, connected) 43 | } 44 | 45 | log.Println("molotov: logged with user/pass") 46 | 47 | // Renew auth token each hours 48 | go func() { 49 | ticker := time.NewTicker(1 * time.Hour) 50 | 51 | for { 52 | select { 53 | case <-ctx.Done(): 54 | return 55 | case <-ticker.C: 56 | connected, err := molotovClient.Auth() 57 | 58 | if err != nil || !connected { 59 | log.Fatal(err, connected) 60 | } 61 | 62 | log.Println("token refreshed", molotovClient.AccessToken) 63 | } 64 | } 65 | }() 66 | 67 | router := mux.NewRouter() 68 | 69 | router.HandleFunc("/", home) 70 | router.HandleFunc("/drm/{id:[0-9]+}", proxy) 71 | router.HandleFunc("/cdn/{id:[0-9]+}.mpd", cdn) 72 | 73 | http.Handle("/", router) 74 | 75 | log.Printf("listening on port %s", port) 76 | log.Fatal(http.ListenAndServe(":"+port, nil)) 77 | } 78 | 79 | func home(w http.ResponseWriter, r *http.Request) { 80 | _, _ = w.Write([]byte("DRM Proxy")) 81 | } 82 | 83 | func proxy(w http.ResponseWriter, r *http.Request) { 84 | w.Header().Set("Access-Control-Allow-Origin", "*") 85 | 86 | if r.Method == http.MethodOptions { 87 | return 88 | } 89 | 90 | vars := mux.Vars(r) 91 | id, _ := strconv.Atoi(vars["id"]) 92 | 93 | var err error 94 | var asset *molotov.Asset 95 | 96 | asset, err = molotovClient.GetAsset(id) 97 | 98 | if err != nil { 99 | http.Error(w, err.Error(), http.StatusInternalServerError) 100 | return 101 | } 102 | 103 | defer func() { 104 | _ = r.Body.Close() 105 | }() 106 | 107 | payload, err := widevine.TodayDRM(asset.DRM.Token, r.Body) 108 | 109 | if err != nil { 110 | http.Error(w, err.Error(), http.StatusInternalServerError) 111 | return 112 | } 113 | 114 | _, _ = w.Write(payload) 115 | } 116 | 117 | func cdn(w http.ResponseWriter, r *http.Request) { 118 | w.Header().Set("Access-Control-Allow-Origin", "*") 119 | 120 | if r.Method == http.MethodOptions { 121 | return 122 | } 123 | 124 | vars := mux.Vars(r) 125 | id, _ := strconv.Atoi(vars["id"]) 126 | 127 | var err error 128 | var asset *molotov.Asset 129 | 130 | asset, err = molotovClient.GetAsset(id) 131 | 132 | if err != nil { 133 | http.Error(w, err.Error(), http.StatusInternalServerError) 134 | return 135 | } 136 | 137 | res, err := http.Get(asset.Stream.URL) 138 | 139 | if err != nil { 140 | http.Error(w, err.Error(), http.StatusInternalServerError) 141 | return 142 | } 143 | 144 | defer func() { 145 | _ = res.Body.Close() 146 | }() 147 | 148 | manifest, err := mpd.Read(res.Body) 149 | 150 | if err != nil { 151 | http.Error(w, err.Error(), http.StatusInternalServerError) 152 | return 153 | } 154 | 155 | // Add CDN base URL 156 | // FIXME we need url without last part, I use path.Dir for this, but this 'break' url, so I fix url with strings.Replace 157 | manifest.BaseURL = path.Dir(asset.Stream.URL) + "/" 158 | manifest.BaseURL = strings.Replace(manifest.BaseURL, "https:/", "https://", 1) 159 | 160 | for _, period := range manifest.Periods { 161 | for id, adaptionSet := range period.AdaptationSets { 162 | if *adaptionSet.ContentType == "text" { 163 | // Remove subtitles AdaptationSet (not working /w JWPlayer) 164 | // But subtitles are not encrypted, so they can me downloaded easily 165 | period.AdaptationSets[id] = nil 166 | } 167 | } 168 | } 169 | 170 | _ = manifest.Write(w) 171 | } 172 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module widevine-proxy 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/gorilla/mux v1.7.4 7 | github.com/pkg/errors v0.9.1 8 | github.com/zencoder/go-dash v0.0.0-20200317150532-ac2f9ccb57c8 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= 2 | github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 3 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 4 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 5 | github.com/zencoder/go-dash v0.0.0-20200317150532-ac2f9ccb57c8 h1:mbWZMLQCBigQNJnEaZkv7IhHZKILu+jS7oD/IbugLt8= 6 | github.com/zencoder/go-dash v0.0.0-20200317150532-ac2f9ccb57c8/go.mod h1:c8Gxxfmh0jmZ6G+ISlpa315WBVkzd8mEhu6gN9mn5Qg= 7 | -------------------------------------------------------------------------------- /internal/molotov/molotov.go: -------------------------------------------------------------------------------- 1 | package molotov 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | const ( 15 | xMolotovAgent = "{\"app_id\":\"electron_app\",\"app_build\":3,\"app_version_name\":\"4.2.1\",\"type\":\"desktop\",\"os_version\":\"macOs new version\",\"electron_version\":\"4.1.5\",\"os\":\"macOS\",\"manufacturer\":\"Apple\",\"serial\":\"7B819232-2DCB-5BD4-8D4F-A27CDB4F90FA\",\"model\":\"MacBook Pro\",\"hasTouchbar\":false,\"brand\":\"Apple\",\"api_version\":8,\"features_supported\":[\"social\",\"download_to_go\",\"new_button_conversion\",\"paywall\",\"channel_separator\",\"download_to_go_lot_2\",\"empty_view_v2\"],\"inner_app_version_name\":\"3.55.0\",\"qa\":false}" 16 | userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Molotov/4.2.1 Chrome/69.0.3497.128 Electron/4.1.5 Safari/537.36" 17 | ) 18 | 19 | type Client struct { 20 | ctx context.Context 21 | user string 22 | pass string 23 | AccessToken string 24 | } 25 | 26 | func New(ctx context.Context, user, pass string) (*Client, error) { 27 | return &Client{ 28 | ctx: ctx, 29 | user: user, 30 | pass: pass, 31 | }, nil 32 | } 33 | 34 | func (c *Client) Auth() (bool, error) { 35 | if c.user == "" || c.pass == "" { 36 | return false, errors.New("no credentials provided") 37 | } 38 | 39 | payload := struct { 40 | GrantType string `json:"grant_type"` 41 | Email string `json:"email"` 42 | Password string `json:"password"` 43 | }{ 44 | GrantType: "password", 45 | Email: c.user, 46 | Password: c.pass, 47 | } 48 | 49 | jPayload, err := json.Marshal(payload) 50 | 51 | if err != nil { 52 | return false, errors.Wrap(err, "json format credentials") 53 | } 54 | 55 | req, err := http.NewRequest("POST", "https://fapi.molotov.tv/v3.1/auth/login", bytes.NewBuffer(jPayload)) 56 | 57 | if err != nil { 58 | return false, errors.Wrap(err, "create GET request") 59 | } 60 | 61 | req.Header.Set("X-Molotov-Agent", xMolotovAgent) 62 | req.Header.Set("User-Agent", userAgent) 63 | 64 | res, err := http.DefaultClient.Do(req) 65 | 66 | if err != nil { 67 | return false, errors.Wrap(err, "post login") 68 | } 69 | 70 | defer func() { 71 | _ = res.Body.Close() 72 | }() 73 | 74 | body, err := ioutil.ReadAll(res.Body) 75 | 76 | if err != nil { 77 | return false, errors.Wrap(err, "read body response") 78 | } 79 | 80 | if res.StatusCode < 200 || res.StatusCode >= 300 { 81 | return false, errors.Errorf("bad http code: %s: %s", res.Status, body) 82 | } 83 | 84 | var data struct { 85 | Auth struct { 86 | AccessToken string `json:"access_token"` 87 | } `json:"auth"` 88 | } 89 | 90 | if err = json.Unmarshal(body, &data); err != nil { 91 | return false, errors.Wrap(err, "json parse response") 92 | } 93 | 94 | if data.Auth.AccessToken == "" { 95 | return false, errors.Errorf("no access token received: %s: %s", res.Status, body) 96 | } 97 | 98 | c.AccessToken = data.Auth.AccessToken 99 | 100 | return true, nil 101 | } 102 | 103 | type Asset struct { 104 | DRM struct { 105 | Token string `json:"token"` 106 | } `json:"drm"` 107 | Stream struct { 108 | URL string `json:"url"` 109 | } `json:"stream"` 110 | } 111 | 112 | func (c *Client) GetAsset(id int) (*Asset, error) { 113 | if c.AccessToken == "" { 114 | return nil, errors.New("no molotov access token available") 115 | } 116 | 117 | req, err := http.NewRequest("GET", fmt.Sprintf("https://fapi.molotov.tv/v2/me/assets?cwatch=true&id=%d&trkCp=season&trkCs=vod&trkOp=home&trkOs=on-tv-77&type=vod&access_token=%s", id, c.AccessToken), nil) 118 | 119 | if err != nil { 120 | return nil, errors.Wrap(err, "create GET asset request") 121 | } 122 | 123 | req.Header.Set("X-Molotov-Agent", xMolotovAgent) 124 | req.Header.Set("User-Agent", userAgent) 125 | 126 | res, err := http.DefaultClient.Do(req) 127 | 128 | if err != nil { 129 | return nil, errors.Wrap(err, "get asset") 130 | } 131 | 132 | defer func() { 133 | _ = res.Body.Close() 134 | }() 135 | 136 | body, err := ioutil.ReadAll(res.Body) 137 | 138 | if err != nil { 139 | return nil, errors.Wrap(err, "read body response") 140 | } 141 | 142 | if res.StatusCode < 200 || res.StatusCode >= 300 { 143 | // TODO re Auth() 144 | return nil, errors.Errorf("bad http code: %s: %s", res.Status, body) 145 | } 146 | 147 | var asset Asset 148 | 149 | if err = json.Unmarshal(body, &asset); err != nil { 150 | return nil, errors.Wrap(err, "json parse response") 151 | } 152 | 153 | if asset.DRM.Token == "" || asset.Stream.URL == "" { 154 | return nil, errors.Errorf("no drm token or stream url received: %s: %s", res.Status, body) 155 | } 156 | 157 | return &asset, nil 158 | } 159 | -------------------------------------------------------------------------------- /internal/widevine/todaydrm.go: -------------------------------------------------------------------------------- 1 | package widevine 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | func TodayDRM(token string, requestBody io.Reader) ([]byte, error) { 14 | if token == "" { 15 | return nil, errors.New("no drm token available") 16 | } 17 | 18 | req, err := http.NewRequest("POST", "https://lic.drmtoday.com/license-proxy-widevine/cenc/", requestBody) 19 | 20 | if err != nil { 21 | return nil, errors.Wrap(err, "create POST request") 22 | } 23 | 24 | req.Header.Set("x-dt-auth-token", token) 25 | 26 | res, err := http.DefaultClient.Do(req) 27 | 28 | if err != nil { 29 | return nil, errors.Wrap(err, "post drm payload") 30 | } 31 | 32 | defer func() { 33 | _ = res.Body.Close() 34 | }() 35 | 36 | body, err := ioutil.ReadAll(res.Body) 37 | 38 | if err != nil { 39 | return nil, errors.Wrap(err, "read body response") 40 | } 41 | 42 | if res.StatusCode < 200 || res.StatusCode >= 300 { 43 | return nil, errors.Errorf("bad http code: %s: %s", res.Status, body) 44 | } 45 | 46 | var data struct { 47 | License string `json:"license"` 48 | } 49 | 50 | if err = json.Unmarshal(body, &data); err != nil { 51 | return nil, errors.Wrap(err, "json parse response") 52 | } 53 | 54 | if data.License == "" { 55 | return nil, errors.Errorf("no license payload received: %s: %s", res.Status, body) 56 | } 57 | 58 | payload, err := base64.StdEncoding.DecodeString(data.License) 59 | 60 | if err != nil { 61 | return nil, errors.Wrap(err, "base64 decode payload") 62 | } 63 | 64 | return payload, nil 65 | } 66 | --------------------------------------------------------------------------------