A music player that connects to your cloud/distributed storage.
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add the IPFS, Google Drive and Dropbox services

+1278 -61
+15 -4
src/Applications/UI.elm
··· 86 86 87 87 init : Flags -> Url -> Nav.Key -> ( Model, Cmd Msg ) 88 88 init flags url key = 89 + let 90 + maybePage = 91 + Page.fromUrl url 92 + in 89 93 ( ----------------------------------------- 90 94 -- Initial model 91 95 ----------------------------------------- ··· 94 98 , isLoading = True 95 99 , navKey = key 96 100 , notifications = [] 97 - , page = Maybe.withDefault Page.Index (Page.fromUrl url) 101 + , page = Maybe.withDefault Page.Index maybePage 98 102 , url = url 99 103 , viewport = flags.viewport 100 104 ··· 111 115 , backdrop = Backdrop.initialModel 112 116 , equalizer = Equalizer.initialModel 113 117 , queue = Queue.initialModel 114 - , sources = Sources.initialModel 118 + , sources = Sources.initialModel (Maybe.andThen Page.sources maybePage) 115 119 , tracks = Tracks.initialModel 116 120 } 117 121 ----------------------------------------- 118 122 -- Initial command 119 123 ----------------------------------------- 120 - , case Page.fromUrl url of 124 + , case maybePage of 121 125 Just _ -> 122 126 Cmd.none 123 127 ··· 218 222 SignOut -> 219 223 { model 220 224 | authentication = Authentication.initialModel model.url 221 - , sources = Sources.initialModel 225 + , sources = Sources.initialModel (Page.sources Page.Index) 222 226 , tracks = Tracks.initialModel 223 227 } 224 228 |> update (BackdropMsg Backdrop.Default) ··· 494 498 |> Sources.AddToCollection 495 499 |> SourcesMsg 496 500 |> updateWithModel model 501 + 502 + ExternalSourceAuthorization urlBuilder -> 503 + model.url 504 + |> Common.urlOrigin 505 + |> urlBuilder 506 + |> Nav.load 507 + |> returnWithModel model 497 508 498 509 ProcessSources -> 499 510 let
+57 -3
src/Applications/UI/Page.elm
··· 1 - module UI.Page exposing (Page(..), fromUrl, sameBase, toString) 1 + module UI.Page exposing (Page(..), fromUrl, sameBase, sources, toString) 2 2 3 + import Sources exposing (Service(..)) 3 4 import UI.Queue.Page as Queue 4 5 import UI.Settings.Page as Settings 5 6 import UI.Sources.Page as Sources 6 7 import Url exposing (Url) 7 8 import Url.Parser exposing (..) 9 + import Url.Parser.Query as Query 8 10 9 11 10 12 ··· 24 26 25 27 26 28 fromUrl : Url -> Maybe Page 27 - fromUrl = 28 - parse route 29 + fromUrl url = 30 + -- For some oauth stuff, replace the query with the fragment 31 + if Maybe.map (String.contains "token=") url.fragment == Just True then 32 + parse route { url | query = url.fragment } 33 + 34 + else 35 + parse route url 29 36 30 37 31 38 toString : Page -> String ··· 64 71 Sources Sources.New -> 65 72 "/sources/new" 66 73 74 + Sources (Sources.NewThroughRedirect Google _) -> 75 + "/sources/new/google" 76 + 77 + Sources (Sources.NewThroughRedirect _ _) -> 78 + "/sources/new" 79 + 67 80 68 81 {-| Are the bases of these two pages the same? 69 82 -} ··· 87 100 88 101 89 102 103 + -- 🔱 ░░ SPECIFIC 104 + 105 + 106 + sources : Page -> Maybe Sources.Page 107 + sources page = 108 + case page of 109 + Sources s -> 110 + Just s 111 + 112 + _ -> 113 + Nothing 114 + 115 + 116 + 90 117 -- ⚗️ 91 118 92 119 ··· 115 142 ----------------------------------------- 116 143 , map (Sources Sources.Index) (s "sources") 117 144 , map (Sources Sources.New) (s "sources" </> s "new") 145 + 146 + -- Oauth 147 + -------- 148 + , map 149 + (\token state -> 150 + { codeOrToken = token, state = state } 151 + |> Sources.NewThroughRedirect Dropbox 152 + |> Sources 153 + ) 154 + (s "sources" 155 + </> s "new" 156 + </> s "dropbox" 157 + <?> Query.string "access_token" 158 + <?> Query.string "state" 159 + ) 160 + , map 161 + (\code state -> 162 + { codeOrToken = code, state = state } 163 + |> Sources.NewThroughRedirect Google 164 + |> Sources 165 + ) 166 + (s "sources" 167 + </> s "new" 168 + </> s "google" 169 + <?> Query.string "code" 170 + <?> Query.string "state" 171 + ) 118 172 ]
+1
src/Applications/UI/Reply.elm
··· 41 41 -- Sources & Tracks 42 42 ----------------------------------------- 43 43 | AddSourceToCollection Source 44 + | ExternalSourceAuthorization (String -> String) 44 45 | ProcessSources 45 46 | RemoveTracksWithSourceId String 46 47 -----------------------------------------
+6 -3
src/Applications/UI/Sources.elm
··· 34 34 } 35 35 36 36 37 - initialModel : Model 38 - initialModel = 37 + initialModel : Maybe Sources.Page -> Model 38 + initialModel maybePage = 39 39 { collection = [] 40 40 , currentTime = Time.millisToPosix 0 41 - , form = Form.initialModel 41 + , form = Form.initialModel maybePage 42 42 , isProcessing = False 43 43 , processingNotificationId = Nothing 44 44 } ··· 150 150 index model 151 151 152 152 New -> 153 + List.map (Html.map FormMsg) (Form.new model.form) 154 + 155 + NewThroughRedirect _ _ -> 153 156 List.map (Html.map FormMsg) (Form.new model.form) 154 157 ) 155 158
+67 -37
src/Applications/UI/Sources/Form.elm
··· 1 1 module UI.Sources.Form exposing (FormStep(..), Model, Msg(..), initialModel, new, takeStepBackwards, takeStepForwards, update) 2 2 3 + import Browser.Navigation as Nav 3 4 import Chunky exposing (..) 4 5 import Conditional exposing (..) 5 6 import Dict ··· 13 14 import Return3 as Return exposing (..) 14 15 import Sources exposing (..) 15 16 import Sources.Services as Services 17 + import Sources.Services.Dropbox 18 + import Sources.Services.Google 16 19 import Tachyons.Classes as T 17 20 import UI.Kit exposing (ButtonType(..), select) 18 21 import UI.Navigation exposing (..) 19 22 import UI.Page 20 23 import UI.Reply exposing (Reply) 21 - import UI.Sources.Page 24 + import UI.Sources.Page as Sources 22 25 23 26 24 27 ··· 26 29 27 30 28 31 type alias Model = 29 - { step : FormStep, context : Source } 32 + { step : FormStep 33 + , context : Source 34 + } 30 35 31 36 32 37 type FormStep ··· 35 40 | By 36 41 37 42 38 - initialModel : Model 39 - initialModel = 40 - { step = Where, context = defaultContext } 43 + initialModel : Maybe Sources.Page -> Model 44 + initialModel maybePage = 45 + { step = 46 + case maybePage of 47 + Just (Sources.NewThroughRedirect _ _) -> 48 + How 49 + 50 + _ -> 51 + Where 52 + , context = 53 + case maybePage of 54 + Just (Sources.NewThroughRedirect Dropbox args) -> 55 + { defaultContext 56 + | data = Sources.Services.Dropbox.authorizationSourceData args 57 + , service = Dropbox 58 + } 59 + 60 + Just (Sources.NewThroughRedirect Google args) -> 61 + { defaultContext 62 + | data = Sources.Services.Google.authorizationSourceData args 63 + , service = Google 64 + } 65 + 66 + _ -> 67 + defaultContext 68 + } 41 69 42 70 43 71 defaultContext : Source ··· 70 98 71 99 update : Msg -> Model -> Return Model Msg Reply 72 100 update msg model = 73 - ( ----------------------------------------- 74 - -- Model 75 - ----------------------------------------- 76 - case msg of 101 + case msg of 77 102 AddSource -> 78 - { model | step = Where, context = defaultContext } 103 + returnRepliesWithModel 104 + { model | step = Where, context = defaultContext } 105 + [ UI.Reply.GoToPage (UI.Page.Sources Sources.Index) 106 + , UI.Reply.AddSourceToCollection model.context 107 + ] 79 108 80 109 Bypass -> 81 - model 110 + return model 82 111 83 112 SelectService serviceKey -> 84 113 case Services.keyToType serviceKey of ··· 88 117 ( model.context 89 118 , Services.initialData service 90 119 ) 120 + 121 + newContext = 122 + { context | data = data, service = service } 91 123 in 92 - { model | context = { context | data = data, service = service } } 124 + return { model | context = newContext } 93 125 94 126 Nothing -> 95 - model 127 + return model 96 128 97 129 SetData key value -> 98 130 let 99 131 context = 100 132 model.context 101 133 102 - trimmedValue = 103 - String.trim value 134 + updatedData = 135 + Dict.insert key (String.trim value) context.data 104 136 105 - updatedData = 106 - Dict.insert key trimmedValue context.data 137 + newContext = 138 + { context | data = updatedData } 107 139 in 108 - { model | context = { context | data = updatedData } } 140 + return { model | context = newContext } 109 141 110 142 TakeStep -> 111 - { model | step = takeStepForwards model.step } 143 + case ( model.step, model.context.service ) of 144 + ( Where, Dropbox ) -> 145 + model.context.data 146 + |> Sources.Services.Dropbox.authorizationUrl 147 + |> UI.Reply.ExternalSourceAuthorization 148 + |> returnReplyWithModel model 112 149 113 - TakeStepBackwards -> 114 - { model | step = takeStepBackwards model.step } 115 - ----------------------------------------- 116 - -- Command 117 - ----------------------------------------- 118 - , Cmd.none 119 - ----------------------------------------- 120 - -- Reply 121 - ----------------------------------------- 122 - , case msg of 123 - AddSource -> 124 - [ UI.Reply.GoToPage (UI.Page.Sources UI.Sources.Page.Index) 125 - , UI.Reply.AddSourceToCollection model.context 126 - ] 150 + ( Where, Google ) -> 151 + model.context.data 152 + |> Sources.Services.Google.authorizationUrl 153 + |> UI.Reply.ExternalSourceAuthorization 154 + |> returnReplyWithModel model 155 + 156 + _ -> 157 + return { model | step = takeStepForwards model.step } 127 158 128 - _ -> 129 - [] 130 - ) 159 + TakeStepBackwards -> 160 + return { model | step = takeStepBackwards model.step } 131 161 132 162 133 163 takeStepForwards : FormStep -> FormStep ··· 175 205 UI.Navigation.local 176 206 [ ( Icon Icons.arrow_back 177 207 , Label "Back to list" Hidden 178 - , GoToPage (UI.Page.Sources UI.Sources.Page.Index) 208 + , GoToPage (UI.Page.Sources Sources.Index) 179 209 ) 180 210 ] 181 211
+5
src/Applications/UI/Sources/Page.elm
··· 1 1 module UI.Sources.Page exposing (Page(..)) 2 2 3 + import Sources exposing (Service) 4 + 5 + 6 + 3 7 -- 🌳 4 8 5 9 6 10 type Page 7 11 = Index 8 12 | New 13 + | NewThroughRedirect Service { codeOrToken : Maybe String, state : Maybe String }
+6 -3
src/Javascript/audio-engine.js
··· 102 102 // Create audio node 103 103 let audioNode 104 104 105 - audioNode = createAudioElement(orchestrion, queueItem) 106 - audioNode.context = context.createMediaElementSource(audioNode) 107 - audioNode.context.connect(volume) 105 + transformUrl(queueItem.url).then(url => { 106 + queueItem = Object.assign({}, queueItem, { url: url }) 107 + audioNode = createAudioElement(orchestrion, queueItem) 108 + audioNode.context = context.createMediaElementSource(audioNode) 109 + audioNode.context.connect(volume) 110 + }) 108 111 } 109 112 110 113
+6 -1
src/Library/Dict/Ext.elm
··· 1 - module Dict.Ext exposing (fetch, fetchUnknown) 1 + module Dict.Ext exposing (fetch, fetchUnknown, unionFlipped) 2 2 3 3 import Dict exposing (Dict) 4 + 5 + 6 + unionFlipped : Dict comparable v -> Dict comparable v -> Dict comparable v 7 + unionFlipped a b = 8 + Dict.union b a 4 9 5 10 6 11 fetch : comparable -> v -> Dict comparable v -> v
+2 -2
src/Library/Return2.elm
··· 33 33 34 34 35 35 36 - -- 🔱 ░░░ ALIASES 36 + -- 🔱 ░░ ALIASES 37 37 38 38 39 39 withModel = ··· 41 41 42 42 43 43 44 - -- 🔱 ░░░ MODIFICATIONS 44 + -- 🔱 ░░ MODIFICATIONS 45 45 46 46 47 47 addCommand : Cmd msg -> Return model msg -> Return model msg
+4 -4
src/Library/Return3.elm
··· 59 59 60 60 61 61 62 - -- 🔱 ░░░ ALIASES 62 + -- 🔱 ░░ ALIASES 63 63 64 64 65 65 commandWithModel = ··· 75 75 76 76 77 77 78 - -- 🔱 ░░░ MODIFICATIONS 78 + -- 🔱 ░░ MODIFICATIONS 79 79 80 80 81 81 addCommand : Cmd msg -> Return model msg reply -> Return model msg reply ··· 115 115 116 116 117 117 118 - -- 🔱 ░░░ WIELDING 118 + -- 🔱 ░░ WIELDING 119 119 120 120 121 121 wield : ··· 167 167 168 168 169 169 170 - -- 🔱 ░░░ DEBOUNCER 170 + -- 🔱 ░░ DEBOUNCER 171 171 172 172 173 173 fromDebouncer : ( model, Cmd msg, Maybe reply ) -> Return model msg reply
+3
src/Library/Sources.elm
··· 40 40 41 41 type Service 42 42 = AmazonS3 43 + | Dropbox 44 + | Google 45 + | Ipfs 43 46 44 47 45 48
+105
src/Library/Sources/Services.elm
··· 7 7 import Sources exposing (..) 8 8 import Sources.Processing exposing (..) 9 9 import Sources.Services.AmazonS3 as AmazonS3 10 + import Sources.Services.Dropbox as Dropbox 11 + import Sources.Services.Google as Google 12 + import Sources.Services.Ipfs as Ipfs 10 13 import Time 11 14 12 15 ··· 20 23 AmazonS3 -> 21 24 AmazonS3.initialData 22 25 26 + Dropbox -> 27 + Dropbox.initialData 28 + 29 + Ipfs -> 30 + Ipfs.initialData 31 + 32 + Google -> 33 + Google.initialData 34 + 23 35 24 36 makeTrackUrl : Service -> Time.Posix -> SourceData -> HttpMethod -> String -> String 25 37 makeTrackUrl service = ··· 27 39 AmazonS3 -> 28 40 AmazonS3.makeTrackUrl 29 41 42 + Dropbox -> 43 + Dropbox.makeTrackUrl 44 + 45 + Ipfs -> 46 + Ipfs.makeTrackUrl 47 + 48 + Google -> 49 + Google.makeTrackUrl 50 + 30 51 31 52 makeTree : 32 53 Service ··· 40 61 AmazonS3 -> 41 62 AmazonS3.makeTree 42 63 64 + Dropbox -> 65 + Dropbox.makeTree 66 + 67 + Ipfs -> 68 + Ipfs.makeTree 69 + 70 + Google -> 71 + Google.makeTree 72 + 43 73 44 74 parseErrorResponse : Service -> String -> String 45 75 parseErrorResponse service = ··· 47 77 AmazonS3 -> 48 78 AmazonS3.parseErrorResponse 49 79 80 + Dropbox -> 81 + Dropbox.parseErrorResponse 82 + 83 + Ipfs -> 84 + Ipfs.parseErrorResponse 85 + 86 + Google -> 87 + Google.parseErrorResponse 88 + 50 89 51 90 parsePreparationResponse : Service -> String -> SourceData -> Marker -> PrepationAnswer Marker 52 91 parsePreparationResponse service = ··· 54 93 AmazonS3 -> 55 94 AmazonS3.parsePreparationResponse 56 95 96 + Dropbox -> 97 + Dropbox.parsePreparationResponse 98 + 99 + Ipfs -> 100 + Ipfs.parsePreparationResponse 101 + 102 + Google -> 103 + Google.parsePreparationResponse 104 + 57 105 58 106 parseTreeResponse : Service -> String -> Marker -> TreeAnswer Marker 59 107 parseTreeResponse service = ··· 61 109 AmazonS3 -> 62 110 AmazonS3.parseTreeResponse 63 111 112 + Dropbox -> 113 + Dropbox.parseTreeResponse 114 + 115 + Ipfs -> 116 + Ipfs.parseTreeResponse 117 + 118 + Google -> 119 + Google.parseTreeResponse 120 + 64 121 65 122 postProcessTree : Service -> List String -> List String 66 123 postProcessTree service = 67 124 case service of 68 125 AmazonS3 -> 69 126 AmazonS3.postProcessTree 127 + 128 + Dropbox -> 129 + Dropbox.postProcessTree 130 + 131 + Ipfs -> 132 + Ipfs.postProcessTree 133 + 134 + Google -> 135 + Google.postProcessTree 70 136 71 137 72 138 prepare : ··· 81 147 AmazonS3 -> 82 148 AmazonS3.prepare 83 149 150 + Dropbox -> 151 + Dropbox.prepare 152 + 153 + Ipfs -> 154 + Ipfs.prepare 155 + 156 + Google -> 157 + Google.prepare 158 + 84 159 85 160 properties : Service -> List Property 86 161 properties service = ··· 88 163 AmazonS3 -> 89 164 AmazonS3.properties 90 165 166 + Dropbox -> 167 + Dropbox.properties 168 + 169 + Ipfs -> 170 + Ipfs.properties 171 + 172 + Google -> 173 + Google.properties 174 + 91 175 92 176 93 177 -- KEYS & LABELS ··· 99 183 "AmazonS3" -> 100 184 Just AmazonS3 101 185 186 + "Dropbox" -> 187 + Just Dropbox 188 + 189 + "Ipfs" -> 190 + Just Ipfs 191 + 192 + "Google" -> 193 + Just Google 194 + 102 195 _ -> 103 196 Nothing 104 197 ··· 109 202 AmazonS3 -> 110 203 "AmazonS3" 111 204 205 + Dropbox -> 206 + "Dropbox" 207 + 208 + Ipfs -> 209 + "Ipfs" 210 + 211 + Google -> 212 + "Google" 213 + 112 214 113 215 {-| Service labels. 114 216 Maps a service key to a label. ··· 116 218 labels : List ( String, String ) 117 219 labels = 118 220 [ ( typeToKey AmazonS3, "Amazon S3" ) 221 + , ( typeToKey Dropbox, "Dropbox" ) 222 + , ( typeToKey Google, "Google Drive" ) 223 + , ( typeToKey Ipfs, "IPFS" ) 119 224 ]
+3 -3
src/Library/Sources/Services/AmazonS3.elm
··· 10 10 11 11 import Dict 12 12 import Http 13 - import Sources exposing (..) 13 + import Sources exposing (Property, SourceData) 14 14 import Sources.Pick 15 15 import Sources.Processing exposing (..) 16 16 import Sources.Services.AmazonS3.Parser as Parser ··· 164 164 165 165 166 166 167 - -- Post 167 + -- POST 168 168 169 169 170 170 {-| Post process the tree results. ··· 178 178 179 179 180 180 181 - -- Track URL 181 + -- TRACK URL 182 182 183 183 184 184 {-| Create a public url for a file.
+240
src/Library/Sources/Services/Dropbox.elm
··· 1 + module Sources.Services.Dropbox exposing (authorizationSourceData, authorizationUrl, defaults, getProperDirectoryPath, initialData, makeTrackUrl, makeTree, parseErrorResponse, parsePreparationResponse, parseTreeResponse, postProcessTree, prepare, properties) 2 + 3 + {-| Dropbox Service. 4 + -} 5 + 6 + import Base64 7 + import Dict 8 + import Dict.Ext as Dict 9 + import Http 10 + import Json.Decode 11 + import Json.Encode 12 + import Regex 13 + import Sources exposing (Property, SourceData) 14 + import Sources.Pick 15 + import Sources.Processing exposing (..) 16 + import Sources.Services.Common exposing (cleanPath, noPrep) 17 + import Sources.Services.Dropbox.Parser as Parser 18 + import Time 19 + import Url 20 + import Url.Builder as Url 21 + 22 + 23 + 24 + -- PROPERTIES 25 + -- 📟 26 + 27 + 28 + defaults = 29 + { appKey = "kwsydtrzban41zr" 30 + , directoryPath = "/" 31 + , name = "Music from Dropbox" 32 + } 33 + 34 + 35 + {-| The list of properties we need from the user. 36 + 37 + Tuple: (property, label, placeholder, isPassword) 38 + Will be used for the forms. 39 + 40 + -} 41 + properties : List Property 42 + properties = 43 + [ { key = "accessToken" 44 + , label = "Access Token" 45 + , placeholder = "..." 46 + , password = True 47 + } 48 + , { key = "appKey" 49 + , label = "App key" 50 + , placeholder = defaults.appKey 51 + , password = False 52 + } 53 + , { key = "directoryPath" 54 + , label = "Directory" 55 + , placeholder = defaults.directoryPath 56 + , password = False 57 + } 58 + ] 59 + 60 + 61 + {-| Initial data set. 62 + -} 63 + initialData : SourceData 64 + initialData = 65 + Dict.fromList 66 + [ ( "accessToken", "" ) 67 + , ( "appKey", defaults.appKey ) 68 + , ( "directoryPath", defaults.directoryPath ) 69 + , ( "name", defaults.name ) 70 + ] 71 + 72 + 73 + 74 + -- AUTHORIZATION 75 + 76 + 77 + {-| Authorization url. 78 + -} 79 + authorizationUrl : SourceData -> String -> String 80 + authorizationUrl sourceData origin = 81 + let 82 + encodeData data = 83 + data 84 + |> Dict.toList 85 + |> List.map (Tuple.mapSecond Json.Encode.string) 86 + |> Json.Encode.object 87 + 88 + state = 89 + sourceData 90 + |> encodeData 91 + |> Json.Encode.encode 0 92 + |> Base64.encode 93 + in 94 + [ ( "response_type", "token" ) 95 + , ( "client_id", Dict.fetch "appKey" "unknown" sourceData ) 96 + , ( "redirect_uri", origin ++ "/sources/new/dropbox" ) 97 + , ( "state", state ) 98 + ] 99 + |> List.map (\( a, b ) -> Url.string a b) 100 + |> Url.toQuery 101 + |> String.append "https://www.dropbox.com/oauth2/authorize" 102 + 103 + 104 + {-| Authorization source data. 105 + -} 106 + authorizationSourceData : { codeOrToken : Maybe String, state : Maybe String } -> SourceData 107 + authorizationSourceData args = 108 + args.state 109 + |> Maybe.andThen (Base64.decode >> Result.toMaybe) 110 + |> Maybe.withDefault "{}" 111 + |> Json.Decode.decodeString (Json.Decode.dict Json.Decode.string) 112 + |> Result.withDefault Dict.empty 113 + |> Dict.unionFlipped initialData 114 + |> Dict.update "accessToken" (\_ -> args.codeOrToken) 115 + 116 + 117 + 118 + -- PREPARATION 119 + 120 + 121 + prepare : String -> SourceData -> Marker -> (Result Http.Error String -> msg) -> Maybe (Cmd msg) 122 + prepare _ _ _ _ = 123 + Nothing 124 + 125 + 126 + 127 + -- TREE 128 + 129 + 130 + {-| Create a directory tree. 131 + 132 + List all the tracks in the bucket. 133 + Or a specific directory in the bucket. 134 + 135 + -} 136 + makeTree : SourceData -> Marker -> Time.Posix -> (Result Http.Error String -> msg) -> Cmd msg 137 + makeTree srcData marker currentTime toMsg = 138 + let 139 + accessToken = 140 + Dict.fetch "accessToken" "" srcData 141 + 142 + body = 143 + (case marker of 144 + TheBeginning -> 145 + [ ( "limit", Json.Encode.int 2000 ) 146 + , ( "path", Json.Encode.string (getProperDirectoryPath srcData) ) 147 + , ( "recursive", Json.Encode.bool True ) 148 + ] 149 + 150 + InProgress cursor -> 151 + [ ( "cursor", Json.Encode.string cursor ) 152 + ] 153 + 154 + TheEnd -> 155 + [] 156 + ) 157 + |> Json.Encode.object 158 + |> Http.jsonBody 159 + 160 + url = 161 + case marker of 162 + TheBeginning -> 163 + "https://api.dropboxapi.com/2/files/list_folder" 164 + 165 + InProgress _ -> 166 + "https://api.dropboxapi.com/2/files/list_folder/continue" 167 + 168 + TheEnd -> 169 + "" 170 + in 171 + Http.request 172 + { method = "POST" 173 + , headers = [ Http.header "Authorization" ("Bearer " ++ accessToken) ] 174 + , url = url 175 + , body = body 176 + , expect = Http.expectString toMsg 177 + , timeout = Nothing 178 + , tracker = Nothing 179 + } 180 + 181 + 182 + getProperDirectoryPath : SourceData -> String 183 + getProperDirectoryPath srcData = 184 + let 185 + path = 186 + srcData 187 + |> Dict.get "directoryPath" 188 + |> Maybe.withDefault defaults.directoryPath 189 + |> cleanPath 190 + in 191 + if path == "" then 192 + "" 193 + 194 + else 195 + "/" ++ path 196 + 197 + 198 + {-| Re-export parser functions. 199 + -} 200 + parsePreparationResponse : String -> SourceData -> Marker -> PrepationAnswer Marker 201 + parsePreparationResponse = 202 + noPrep 203 + 204 + 205 + parseTreeResponse : String -> Marker -> TreeAnswer Marker 206 + parseTreeResponse = 207 + Parser.parseTreeResponse 208 + 209 + 210 + parseErrorResponse : String -> String 211 + parseErrorResponse = 212 + Parser.parseErrorResponse 213 + 214 + 215 + 216 + -- POST 217 + 218 + 219 + {-| Post process the tree results. 220 + 221 + !!! Make sure we only use music files that we can use. 222 + 223 + -} 224 + postProcessTree : List String -> List String 225 + postProcessTree = 226 + Sources.Pick.selectMusicFiles 227 + 228 + 229 + 230 + -- TRACK URL 231 + 232 + 233 + {-| Create a public url for a file. 234 + 235 + We need this to play the track. 236 + 237 + -} 238 + makeTrackUrl : Time.Posix -> SourceData -> HttpMethod -> String -> String 239 + makeTrackUrl currentTime srcData method pathToFile = 240 + "dropbox://" ++ Dict.fetch "accessToken" "" srcData ++ "@" ++ pathToFile
+41
src/Library/Sources/Services/Dropbox/Parser.elm
··· 1 + module Sources.Services.Dropbox.Parser exposing (parseErrorResponse, parseTreeResponse) 2 + 3 + import Json.Decode exposing (..) 4 + import Sources.Processing exposing (Marker(..), TreeAnswer) 5 + 6 + 7 + 8 + -- TREE 9 + 10 + 11 + parseTreeResponse : String -> Marker -> TreeAnswer Marker 12 + parseTreeResponse response _ = 13 + let 14 + hasMore = 15 + decodeString (field "has_more" bool) response 16 + 17 + cursor = 18 + decodeString (field "cursor" string) response 19 + 20 + paths = 21 + decodeString 22 + (field "entries" <| list <| field "path_display" string) 23 + response 24 + in 25 + { filePaths = Result.withDefault [] paths 26 + , marker = 27 + if Result.withDefault False hasMore then 28 + InProgress (Result.withDefault "" cursor) 29 + 30 + else 31 + TheEnd 32 + } 33 + 34 + 35 + 36 + -- Error 37 + 38 + 39 + parseErrorResponse : String -> String 40 + parseErrorResponse response = 41 + response
+293
src/Library/Sources/Services/Google.elm
··· 1 + module Sources.Services.Google exposing (authorizationSourceData, authorizationUrl, defaultClientId, defaults, initialData, makeTrackUrl, makeTree, parseErrorResponse, parsePreparationResponse, parseTreeResponse, postProcessTree, prepare, properties) 2 + 3 + {-| Google Drive Service. 4 + -} 5 + 6 + import Base64 7 + import Dict 8 + import Dict.Ext as Dict 9 + import Http 10 + import Json.Decode 11 + import Json.Encode 12 + import Regex 13 + import Sources exposing (Property, SourceData) 14 + import Sources.Pick 15 + import Sources.Processing exposing (..) 16 + import Sources.Services.Google.Parser as Parser 17 + import Time 18 + import Url 19 + import Url.Builder as Url 20 + 21 + 22 + 23 + -- PROPERTIES 24 + -- 📟 25 + 26 + 27 + defaults = 28 + { clientId = defaultClientId 29 + , clientSecret = "uHBInBeGnA38FOlpLTEyPlUv" 30 + , folderId = "" 31 + , name = "Music from Google Drive" 32 + } 33 + 34 + 35 + defaultClientId : String 36 + defaultClientId = 37 + String.concat 38 + [ "720114869239-74amkqeila5ursobjqvo9c263u1cllhu" 39 + , ".apps.googleusercontent.com" 40 + ] 41 + 42 + 43 + {-| The list of properties we need from the user. 44 + 45 + Tuple: (property, label, placeholder, isPassword) 46 + Will be used for the forms. 47 + 48 + -} 49 + properties : List Property 50 + properties = 51 + [ { key = "authCode" 52 + , label = "Auth Code" 53 + , placeholder = "..." 54 + , password = True 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 + , { key = "folderId" 67 + , label = "Folder Id (Optional)" 68 + , placeholder = defaults.folderId 69 + , password = False 70 + } 71 + ] 72 + 73 + 74 + {-| Initial data set. 75 + -} 76 + initialData : SourceData 77 + initialData = 78 + Dict.fromList 79 + [ ( "authCode", "" ) 80 + , ( "clientId", defaults.clientId ) 81 + , ( "clientSecret", defaults.clientSecret ) 82 + , ( "folderId", defaults.folderId ) 83 + , ( "name", defaults.name ) 84 + ] 85 + 86 + 87 + 88 + -- AUTHORIZATION 89 + 90 + 91 + {-| Authorization url. 92 + -} 93 + authorizationUrl : SourceData -> String -> String 94 + authorizationUrl sourceData origin = 95 + let 96 + encodeData data = 97 + data 98 + |> Dict.toList 99 + |> List.map (Tuple.mapSecond Json.Encode.string) 100 + |> Json.Encode.object 101 + 102 + state = 103 + sourceData 104 + |> encodeData 105 + |> Json.Encode.encode 0 106 + |> Base64.encode 107 + in 108 + [ ( "access_type", "offline" ) 109 + , ( "client_id", Dict.fetch "clientId" "unknown" sourceData ) 110 + , ( "prompt", "consent" ) 111 + , ( "redirect_uri", origin ++ "/sources/new/google" ) 112 + , ( "response_type", "code" ) 113 + , ( "scope", "https://www.googleapis.com/auth/drive.readonly" ) 114 + , ( "state", state ) 115 + ] 116 + |> List.map (\( a, b ) -> Url.string a b) 117 + |> Url.toQuery 118 + |> String.append "https://accounts.google.com/o/oauth2/v2/auth" 119 + 120 + 121 + {-| Authorization source data. 122 + -} 123 + authorizationSourceData : { codeOrToken : Maybe String, state : Maybe String } -> SourceData 124 + authorizationSourceData args = 125 + args.state 126 + |> Maybe.andThen (Base64.decode >> Result.toMaybe) 127 + |> Maybe.withDefault "{}" 128 + |> Json.Decode.decodeString (Json.Decode.dict Json.Decode.string) 129 + |> Result.withDefault Dict.empty 130 + |> Dict.unionFlipped initialData 131 + |> Dict.update "authCode" (\_ -> args.codeOrToken) 132 + 133 + 134 + 135 + -- PREPARATION 136 + 137 + 138 + {-| Before processing we need to prepare the source. 139 + In this case this means that we will refresh the `access_token`. 140 + Or if we don't have an access token yet, get one. 141 + -} 142 + prepare : String -> SourceData -> Marker -> (Result Http.Error String -> msg) -> Maybe (Cmd msg) 143 + prepare origin srcData _ toMsg = 144 + let 145 + maybeCode = 146 + Dict.get "authCode" srcData 147 + 148 + queryParams = 149 + case maybeCode of 150 + -- Exchange authorization code for access token & request token 151 + Just authCode -> 152 + [ ( "client_id", Dict.fetch "clientId" "" srcData ) 153 + , ( "client_secret", Dict.fetch "clientSecret" "" srcData ) 154 + , ( "code", Dict.fetch "authCode" "" srcData ) 155 + , ( "grant_type", "authorization_code" ) 156 + , ( "redirect_uri", origin ++ "/sources/new/google" ) 157 + ] 158 + 159 + -- Refresh access token 160 + Nothing -> 161 + [ ( "client_id", Dict.fetch "clientId" "" srcData ) 162 + , ( "client_secret", Dict.fetch "clientSecret" "" srcData ) 163 + , ( "refresh_token", Dict.fetch "refreshToken" "" srcData ) 164 + , ( "grant_type", "refresh_token" ) 165 + ] 166 + 167 + query = 168 + queryParams 169 + |> List.map (\( a, b ) -> Url.string a b) 170 + |> Url.toQuery 171 + 172 + url = 173 + "https://www.googleapis.com/oauth2/v4/token" ++ query 174 + in 175 + (Just << Http.post) 176 + { url = url 177 + , body = Http.emptyBody 178 + , expect = Http.expectString toMsg 179 + } 180 + 181 + 182 + 183 + -- TREE 184 + 185 + 186 + {-| Create a directory tree. 187 + 188 + List all the tracks in the bucket. 189 + Or a specific directory in the bucket. 190 + 191 + -} 192 + makeTree : SourceData -> Marker -> Time.Posix -> (Result Http.Error String -> msg) -> Cmd msg 193 + makeTree srcData marker currentTime toMsg = 194 + let 195 + accessToken = 196 + Dict.fetch "accessToken" "" srcData 197 + 198 + folderId = 199 + Dict.fetch "folderId" "" srcData 200 + 201 + queryBase = 202 + [ "mimeType contains 'audio/'" ] 203 + 204 + query = 205 + case folderId of 206 + "" -> 207 + queryBase 208 + 209 + fid -> 210 + queryBase ++ [ "'" ++ fid ++ "' in parents" ] 211 + 212 + paramsBase = 213 + [ ( "pageSize", "1000" ) 214 + , ( "q", String.join " and " query ) 215 + , ( "spaces", "drive" ) 216 + ] 217 + 218 + params = 219 + (case marker of 220 + InProgress cursor -> 221 + [ ( "pageToken", cursor ) 222 + ] 223 + 224 + _ -> 225 + [] 226 + ) 227 + |> List.append paramsBase 228 + |> List.map (\( a, b ) -> Url.string a b) 229 + |> Url.toQuery 230 + in 231 + Http.request 232 + { method = "GET" 233 + , headers = [ Http.header "Authorization" ("Bearer " ++ accessToken) ] 234 + , url = "https://www.googleapis.com/drive/v3/files" ++ params 235 + , body = Http.emptyBody 236 + , expect = Http.expectString toMsg 237 + , timeout = Nothing 238 + , tracker = Nothing 239 + } 240 + 241 + 242 + {-| Re-export parser functions. 243 + -} 244 + parsePreparationResponse : String -> SourceData -> Marker -> PrepationAnswer Marker 245 + parsePreparationResponse = 246 + Parser.parsePreparationResponse 247 + 248 + 249 + parseTreeResponse : String -> Marker -> TreeAnswer Marker 250 + parseTreeResponse = 251 + Parser.parseTreeResponse 252 + 253 + 254 + parseErrorResponse : String -> String 255 + parseErrorResponse = 256 + Parser.parseErrorResponse 257 + 258 + 259 + 260 + -- POST 261 + 262 + 263 + {-| Post process the tree results. 264 + 265 + !!! Make sure we only use music files that we can use. 266 + 267 + -} 268 + postProcessTree : List String -> List String 269 + postProcessTree = 270 + identity 271 + 272 + 273 + 274 + -- TRACK URL 275 + 276 + 277 + {-| Create a public url for a file. 278 + 279 + We need this to play the track. 280 + 281 + -} 282 + makeTrackUrl : Time.Posix -> SourceData -> HttpMethod -> String -> String 283 + makeTrackUrl currentTime srcData method fileId = 284 + let 285 + accessToken = 286 + Dict.fetch "accessToken" "" srcData 287 + in 288 + String.concat 289 + [ "https://www.googleapis.com/drive/v3/files/" 290 + , fileId 291 + , "?alt=media&access_token=" 292 + , accessToken 293 + ]
+90
src/Library/Sources/Services/Google/Parser.elm
··· 1 + module Sources.Services.Google.Parser exposing (fileDecoder, parseErrorResponse, parsePreparationResponse, parseTreeResponse) 2 + 3 + import Dict 4 + import Json.Decode exposing (..) 5 + import Maybe.Extra 6 + import Sources exposing (SourceData) 7 + import Sources.Pick 8 + import Sources.Processing exposing (Marker(..), PrepationAnswer, TreeAnswer) 9 + 10 + 11 + 12 + -- PREPARATION 13 + 14 + 15 + parsePreparationResponse : String -> SourceData -> Marker -> PrepationAnswer Marker 16 + parsePreparationResponse response srcData _ = 17 + let 18 + newAccessToken = 19 + response 20 + |> decodeString (field "access_token" string) 21 + |> Result.withDefault "" 22 + 23 + maybeRefreshToken = 24 + response 25 + |> decodeString (maybe <| field "refresh_token" string) 26 + |> Result.toMaybe 27 + |> Maybe.Extra.join 28 + 29 + refreshTokenUpdater dict = 30 + case maybeRefreshToken of 31 + Just refreshToken -> 32 + Dict.insert "refreshToken" refreshToken dict 33 + 34 + Nothing -> 35 + dict 36 + in 37 + srcData 38 + |> Dict.insert "accessToken" newAccessToken 39 + |> refreshTokenUpdater 40 + |> Dict.remove "authCode" 41 + |> (\s -> { sourceData = s, marker = TheEnd }) 42 + 43 + 44 + 45 + -- TREE 46 + 47 + 48 + parseTreeResponse : String -> Marker -> TreeAnswer Marker 49 + parseTreeResponse response _ = 50 + let 51 + nextPageToken = 52 + response 53 + |> decodeString (maybe <| field "nextPageToken" string) 54 + |> Result.toMaybe 55 + |> Maybe.Extra.join 56 + 57 + files = 58 + decodeString 59 + (field "files" <| list fileDecoder) 60 + response 61 + in 62 + { filePaths = 63 + files 64 + |> Result.withDefault [] 65 + |> List.filter (Tuple.second >> Sources.Pick.isMusicFile) 66 + |> List.map Tuple.first 67 + , marker = 68 + case nextPageToken of 69 + Just token -> 70 + InProgress token 71 + 72 + Nothing -> 73 + TheEnd 74 + } 75 + 76 + 77 + fileDecoder : Decoder ( String, String ) 78 + fileDecoder = 79 + map2 Tuple.pair 80 + (field "id" string) 81 + (field "name" string) 82 + 83 + 84 + 85 + -- Error 86 + 87 + 88 + parseErrorResponse : String -> String 89 + parseErrorResponse response = 90 + response
+174
src/Library/Sources/Services/Ipfs.elm
··· 1 + module Sources.Services.Ipfs exposing (defaults, initialData, makeTrackUrl, makeTree, parseErrorResponse, parsePreparationResponse, parseTreeResponse, postProcessTree, prepare, properties) 2 + 3 + {-| IPFS Service. 4 + 5 + Resources: 6 + 7 + - <https://ipfs.io/docs/api/> 8 + 9 + -} 10 + 11 + import Dict 12 + import Http 13 + import Sources exposing (Property, SourceData) 14 + import Sources.Processing exposing (..) 15 + import Sources.Services.Common exposing (cleanPath, noPrep) 16 + import Sources.Services.Ipfs.Marker as Marker 17 + import Sources.Services.Ipfs.Parser as Parser 18 + import Time 19 + 20 + 21 + 22 + -- PROPERTIES 23 + -- 📟 24 + 25 + 26 + defaults = 27 + { gateway = "http://localhost:8080" 28 + , name = "Music from IPFS" 29 + } 30 + 31 + 32 + {-| The list of properties we need from the user. 33 + 34 + Tuple: (property, label, placeholder, isPassword) 35 + Will be used for the forms. 36 + 37 + -} 38 + properties : List Property 39 + properties = 40 + [ { key = "directoryHash" 41 + , label = "Directory object hash" 42 + , placeholder = "QmVLDAhCY3X9P2u" 43 + , password = False 44 + } 45 + , { key = "gateway" 46 + , label = "Read-only gateway" 47 + , placeholder = defaults.gateway 48 + , password = False 49 + } 50 + ] 51 + 52 + 53 + {-| Initial data set. 54 + -} 55 + initialData : SourceData 56 + initialData = 57 + Dict.fromList 58 + [ ( "directoryHash", "" ) 59 + , ( "name", defaults.name ) 60 + , ( "gateway", defaults.gateway ) 61 + ] 62 + 63 + 64 + 65 + -- PREPARATION 66 + 67 + 68 + prepare : String -> SourceData -> Marker -> (Result Http.Error String -> msg) -> Maybe (Cmd msg) 69 + prepare _ _ _ _ = 70 + Nothing 71 + 72 + 73 + 74 + -- TREE 75 + 76 + 77 + {-| Create a directory tree. 78 + -} 79 + makeTree : SourceData -> Marker -> Time.Posix -> (Result Http.Error String -> msg) -> Cmd msg 80 + makeTree srcData marker _ resultMsg = 81 + let 82 + gateway = 83 + srcData 84 + |> Dict.get "gateway" 85 + |> Maybe.withDefault defaults.gateway 86 + |> String.foldr 87 + (\char acc -> 88 + if String.isEmpty acc && char == '/' then 89 + acc 90 + 91 + else 92 + String.cons char acc 93 + ) 94 + "" 95 + 96 + hash = 97 + case marker of 98 + InProgress _ -> 99 + marker 100 + |> Marker.takeOne 101 + |> Maybe.withDefault "MISSING_HASH" 102 + 103 + _ -> 104 + srcData 105 + |> Dict.get "directoryHash" 106 + |> Maybe.withDefault "MISSING_HASH" 107 + 108 + url = 109 + gateway ++ "/api/v0/ls?arg=" ++ hash ++ "&encoding=json" 110 + in 111 + Http.get 112 + { url = url 113 + , expect = Http.expectString resultMsg 114 + } 115 + 116 + 117 + {-| Re-export parser functions. 118 + -} 119 + parsePreparationResponse : String -> SourceData -> Marker -> PrepationAnswer Marker 120 + parsePreparationResponse = 121 + noPrep 122 + 123 + 124 + parseTreeResponse : String -> Marker -> TreeAnswer Marker 125 + parseTreeResponse = 126 + Parser.parseTreeResponse 127 + 128 + 129 + parseErrorResponse : String -> String 130 + parseErrorResponse = 131 + identity 132 + 133 + 134 + 135 + -- POST 136 + 137 + 138 + {-| Post process the tree results. 139 + 140 + !!! Make sure we only use music files that we can use. 141 + 142 + -} 143 + postProcessTree : List String -> List String 144 + postProcessTree = 145 + identity 146 + 147 + 148 + 149 + -- TRACK URL 150 + 151 + 152 + {-| Create a public url for a file. 153 + 154 + We need this to play the track. 155 + 156 + -} 157 + makeTrackUrl : Time.Posix -> SourceData -> HttpMethod -> String -> String 158 + makeTrackUrl _ srcData _ hash = 159 + let 160 + gateway = 161 + srcData 162 + |> Dict.get "gateway" 163 + |> Maybe.withDefault defaults.gateway 164 + |> String.foldr 165 + (\char acc -> 166 + if String.isEmpty acc && char == '/' then 167 + acc 168 + 169 + else 170 + String.cons char acc 171 + ) 172 + "" 173 + in 174 + gateway ++ "/ipfs/" ++ hash
+93
src/Library/Sources/Services/Ipfs/Marker.elm
··· 1 + module Sources.Services.Ipfs.Marker exposing (concat, removeOne, separator, takeOne) 2 + 3 + {-| Marker stuff for IPFS. 4 + 5 + The IPFS API currently doesn't have a way to return a tree. 6 + So we have build one ourselves. 7 + 8 + How it works: 9 + 10 + 1. We list the objects in a given directory 11 + 2. We make a list of the sub directories 12 + 3. The marker becomes either: 13 + - InProgress `hashOfSubDirA/hashOfSubDirB/hashOfSubDirC` 14 + - TheEnd 15 + 4. If the marker was of the type `InProgress`, 16 + the next request will make a list of the objects in `hashOfSubDirA`. 17 + And so on ... 18 + 19 + -} 20 + 21 + import Sources.Processing exposing (Marker(..)) 22 + 23 + 24 + separator : String 25 + separator = 26 + " ɑ " 27 + 28 + 29 + 30 + -- In 31 + 32 + 33 + concat : List String -> Marker -> Marker 34 + concat list marker = 35 + let 36 + result = 37 + case marker of 38 + InProgress m -> 39 + [ list, String.split separator m ] 40 + |> List.concat 41 + |> String.join separator 42 + 43 + _ -> 44 + String.join separator list 45 + in 46 + case result of 47 + "" -> 48 + TheEnd 49 + 50 + r -> 51 + InProgress r 52 + 53 + 54 + 55 + -- Out 56 + 57 + 58 + {-| Take the first item and return it. 59 + -} 60 + takeOne : Marker -> Maybe String 61 + takeOne marker = 62 + case marker of 63 + InProgress m -> 64 + m 65 + |> String.split separator 66 + |> List.head 67 + 68 + _ -> 69 + Nothing 70 + 71 + 72 + {-| Remove the first item if there is one. 73 + -} 74 + removeOne : Marker -> Marker 75 + removeOne marker = 76 + case marker of 77 + InProgress m -> 78 + let 79 + tmp = 80 + m 81 + |> String.split separator 82 + |> List.drop 1 83 + |> String.join separator 84 + in 85 + case tmp of 86 + "" -> 87 + TheEnd 88 + 89 + x -> 90 + InProgress x 91 + 92 + _ -> 93 + TheEnd
+65
src/Library/Sources/Services/Ipfs/Parser.elm
··· 1 + module Sources.Services.Ipfs.Parser exposing (Link, linkDecoder, parseTreeResponse, treeDecoder) 2 + 3 + import Json.Decode exposing (..) 4 + import Sources.Pick exposing (isMusicFile) 5 + import Sources.Processing exposing (Marker(..), TreeAnswer) 6 + import Sources.Services.Ipfs.Marker as Marker 7 + 8 + 9 + 10 + -- TREE 11 + 12 + 13 + parseTreeResponse : String -> Marker -> TreeAnswer Marker 14 + parseTreeResponse response previousMarker = 15 + let 16 + links = 17 + case decodeString treeDecoder response of 18 + Ok l -> 19 + l 20 + 21 + Err _ -> 22 + [] 23 + 24 + dirs = 25 + links 26 + |> List.filter (.typ >> (==) 1) 27 + |> List.map .hash 28 + 29 + files = 30 + links 31 + |> List.filter (.typ >> (==) 2) 32 + |> List.filter (.name >> isMusicFile) 33 + |> List.map .hash 34 + in 35 + { filePaths = 36 + files 37 + , marker = 38 + previousMarker 39 + |> Marker.removeOne 40 + |> Marker.concat dirs 41 + } 42 + 43 + 44 + treeDecoder : Decoder (List Link) 45 + treeDecoder = 46 + field "Objects" <| index 0 <| field "Links" <| list linkDecoder 47 + 48 + 49 + 50 + -- Links 51 + 52 + 53 + type alias Link = 54 + { hash : String 55 + , name : String 56 + , typ : Int 57 + } 58 + 59 + 60 + linkDecoder : Decoder Link 61 + linkDecoder = 62 + map3 Link 63 + (field "Hash" string) 64 + (field "Name" string) 65 + (field "Type" int)
+2 -1
src/Static/Html/Application.html
··· 80 80 <!-- Scripts --> 81 81 <script src="/vendor/pep.min.js"></script> 82 82 83 + <script src="/urls.js"></script> 84 + <script src="/audio-engine.js"></script> 83 85 <script src="/application.js"></script> 84 - <script src="/audio-engine.js"></script> 85 86 86 87 <!-- Initialize Boot Procedure --> 87 88 <script src="/index.js"></script>