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 ]