A music player that connects to your cloud/distributed storage.
at main 323 lines 8.7 kB view raw
1module Sources.Services.Google exposing (authorizationSourceData, authorizationUrl, defaultClientId, defaults, initialData, makeTrackUrl, makeTree, parseErrorResponse, parsePreparationResponse, parseTreeResponse, postProcessTree, prepare, properties) 2 3{-| Google Drive Service. 4-} 5 6import Base64 7import Common 8import Conditional exposing (..) 9import Dict 10import Dict.Ext as Dict 11import Http 12import Json.Decode 13import Json.Encode 14import Sources exposing (Property, SourceData) 15import Sources.Processing exposing (..) 16import Sources.Services.Google.Marker as Marker 17import Sources.Services.Google.Parser as Parser 18import String.Path 19import Time 20 21 22 23-- PROPERTIES 24-- 📟 25 26 27defaults = 28 { clientId = defaultClientId 29 , clientSecret = "uHBInBeGnA38FOlpLTEyPlUv" 30 , folderId = "" 31 , name = "Music from Google Drive" 32 } 33 34 35defaultClientId : String 36defaultClientId = 37 String.concat 38 [ "720114869239-74amkqeila5ursobjqvo9c263u1cllhu" 39 , ".apps.googleusercontent.com" 40 ] 41 42 43{-| The list of properties we need from the user. 44 45Tuple: (property, label, placeholder, isPassword) 46Will be used for the forms. 47 48-} 49properties : List Property 50properties = 51 [ { key = "folderId" 52 , label = "Folder Id (Optional)" 53 , placeholder = defaults.folderId 54 , password = False 55 } 56 , { key = "clientId" 57 , label = "Client Id (Google Console)" 58 , placeholder = defaults.clientId 59 , password = False 60 } 61 , { key = "clientSecret" 62 , label = "Client Secret (Google Console)" 63 , placeholder = defaults.clientSecret 64 , password = False 65 } 66 ] 67 68 69{-| Initial data set. 70-} 71initialData : SourceData 72initialData = 73 Dict.fromList 74 [ ( "clientId", defaults.clientId ) 75 , ( "clientSecret", defaults.clientSecret ) 76 , ( "folderId", defaults.folderId ) 77 , ( "name", defaults.name ) 78 ] 79 80 81 82-- AUTHORIZATION 83 84 85{-| Authorization url. 86-} 87authorizationUrl : SourceData -> String -> String 88authorizationUrl sourceData origin = 89 let 90 encodeData data = 91 data 92 |> Dict.toList 93 |> List.map (Tuple.mapSecond Json.Encode.string) 94 |> Json.Encode.object 95 96 state = 97 sourceData 98 |> encodeData 99 |> Json.Encode.encode 0 100 |> Base64.encode 101 in 102 [ ( "access_type", "offline" ) 103 , ( "client_id", Dict.fetch "clientId" "unknown" sourceData ) 104 , ( "prompt", "consent" ) 105 , ( "redirect_uri", origin ++ "?path=sources/new/google" ) 106 , ( "response_type", "code" ) 107 , ( "scope", "https://www.googleapis.com/auth/drive.readonly" ) 108 , ( "state", state ) 109 ] 110 |> Common.queryString 111 |> String.append "https://accounts.google.com/o/oauth2/v2/auth" 112 113 114{-| Authorization source data. 115-} 116authorizationSourceData : { codeOrToken : Maybe String, state : Maybe String } -> SourceData 117authorizationSourceData args = 118 args.state 119 |> Maybe.andThen (Base64.decode >> Result.toMaybe) 120 |> Maybe.withDefault "{}" 121 |> Json.Decode.decodeString (Json.Decode.dict Json.Decode.string) 122 |> Result.withDefault Dict.empty 123 |> Dict.unionFlipped initialData 124 |> Dict.update "authCode" (\_ -> args.codeOrToken) 125 126 127 128-- PREPARATION 129 130 131{-| Before processing we need to prepare the source. 132In this case this means that we will refresh the `access_token`. 133Or if we don't have an access token yet, get one. 134-} 135prepare : String -> SourceData -> Marker -> (Result Http.Error String -> msg) -> Maybe (Cmd msg) 136prepare origin srcData _ resultMsg = 137 let 138 maybeCode = 139 Dict.get "authCode" srcData 140 141 queryParams = 142 case maybeCode of 143 -- Exchange authorization code for access token & request token 144 Just _ -> 145 [ ( "client_id", Dict.fetch "clientId" "" srcData ) 146 , ( "client_secret", Dict.fetch "clientSecret" "" srcData ) 147 , ( "code", Dict.fetch "authCode" "" srcData ) 148 , ( "grant_type", "authorization_code" ) 149 , ( "redirect_uri", origin ++ "?path=sources/new/google" ) 150 ] 151 152 -- Refresh access token 153 Nothing -> 154 [ ( "client_id", Dict.fetch "clientId" "" srcData ) 155 , ( "client_secret", Dict.fetch "clientSecret" "" srcData ) 156 , ( "refresh_token", Dict.fetch "refreshToken" "" srcData ) 157 , ( "grant_type", "refresh_token" ) 158 ] 159 160 query = 161 Common.queryString queryParams 162 163 url = 164 "https://www.googleapis.com/oauth2/v4/token" ++ query 165 in 166 (Just << Http.post) 167 { url = url 168 , body = Http.emptyBody 169 , expect = Http.expectStringResponse resultMsg Common.translateHttpResponse 170 } 171 172 173 174-- TREE 175 176 177{-| Create a directory tree. 178 179List all the tracks in the bucket. 180Or a specific directory in the bucket. 181 182-} 183makeTree : SourceData -> Marker -> Time.Posix -> (Result Http.Error String -> msg) -> Cmd msg 184makeTree srcData marker _ resultMsg = 185 let 186 accessToken = 187 Dict.fetch "accessToken" "" srcData 188 189 folderId = 190 Dict.fetch "folderId" "" srcData 191 192 parentId = 193 marker 194 |> Marker.takeOne 195 |> Maybe.map Marker.itemDirectory 196 |> Maybe.andThen (\dir -> ifThenElse (String.isEmpty dir) Nothing <| Just dir) 197 |> Maybe.withDefault folderId 198 |> String.Path.file 199 200 query = 201 case parentId of 202 "" -> 203 [ "mimeType contains 'audio/'" ] 204 205 pid -> 206 [ "(mimeType contains 'audio/'" 207 , "or mimeType = 'application/vnd.google-apps.folder')" 208 , "and ('" ++ pid ++ "' in parents)" 209 ] 210 211 paramsBase = 212 [ ( "fields" 213 , String.join ", " 214 [ "nextPageToken" 215 , "files/id" 216 , "files/mimeType" 217 , "files/name" 218 , "files/trashed" 219 ] 220 ) 221 , ( "includeItemsFromAllDrives", "true" ) 222 , ( "pageSize", "1000" ) 223 , ( "q", String.concat query ) 224 , ( "spaces", "drive" ) 225 , ( "supportsAllDrives", "true" ) 226 ] 227 228 queryString = 229 (case Marker.takeOne marker of 230 Just (Marker.Param { token }) -> 231 [ ( "pageToken", token ) ] 232 233 _ -> 234 [] 235 ) 236 |> List.append paramsBase 237 |> Common.queryString 238 in 239 Http.request 240 { method = "GET" 241 , headers = [ Http.header "Authorization" ("Bearer " ++ accessToken) ] 242 , url = "https://www.googleapis.com/drive/v3/files" ++ queryString 243 , body = Http.emptyBody 244 , expect = Http.expectStringResponse resultMsg Common.translateHttpResponse 245 , timeout = Nothing 246 , tracker = Nothing 247 } 248 249 250{-| Re-export parser functions. 251-} 252parsePreparationResponse : String -> Time.Posix -> SourceData -> Marker -> PrepationAnswer Marker 253parsePreparationResponse = 254 Parser.parsePreparationResponse 255 256 257parseTreeResponse : String -> Marker -> TreeAnswer Marker 258parseTreeResponse = 259 Parser.parseTreeResponse 260 261 262parseErrorResponse : String -> Maybe String 263parseErrorResponse = 264 Parser.parseErrorResponse 265 266 267 268-- POST 269 270 271{-| Post process the tree results. 272 273!!! Make sure we only use music files that we can use. 274 275-} 276postProcessTree : List String -> List String 277postProcessTree = 278 identity 279 280 281 282-- TRACK URL 283 284 285{-| Create a public url for a file. 286 287We need this to play the track. 288 289-} 290makeTrackUrl : Time.Posix -> String -> SourceData -> HttpMethod -> String -> String 291makeTrackUrl currentTime srcId srcData _ path = 292 let 293 file = 294 String.Path.file path 295 296 fileId = 297 file 298 |> String.split "?" 299 |> List.head 300 |> Maybe.withDefault file 301 302 now = 303 Time.posixToMillis currentTime 304 305 expiresAt = 306 Dict.fetch "expiresAt" (String.fromInt now) srcData 307 in 308 String.concat 309 [ "google://" 310 , Dict.fetch "accessToken" "" srcData 311 , ":" 312 , expiresAt 313 , ":" 314 , Dict.fetch "refreshToken" "" srcData 315 , ":" 316 , Dict.fetch "clientId" "" srcData 317 , ":" 318 , Dict.fetch "clientSecret" "" srcData 319 , ":" 320 , srcId 321 , "@" 322 , fileId 323 ]