Skip to content

Commit 9254c19

Browse files
committed
Initial Sonos support
1 parent ed07e1d commit 9254c19

File tree

6 files changed

+214
-40
lines changed

6 files changed

+214
-40
lines changed

RELEASE_NOTES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Release Notes
22

3+
## 1.5.0 - 2019-08-20
4+
* Sonos support
5+
36
## 1.4.22 - 2019-08-20
47
* Update deps
58
* Use --self-contained for Firmware

src/Client/Client.fs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ let tagsTable (model : Model) (dispatch : Msg -> unit) =
238238
tr [ Id tag.Token ] [
239239
yield td [ Title tag.Token ] [ str tag.Object ]
240240
match tag.Action with
241-
| TagAction.PlayMusik urls ->
241+
| TagAction.PlayMusik urls ->
242242
for url in urls do
243243
yield td [ ] [ a [Href url ] [str tag.Description ] ]
244244
| TagAction.PlayYoutube url -> yield td [ ] [ a [Href url ] [str tag.Description ] ]

src/PiServer/PiServer.fs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,8 @@ let update (msg:Msg) (model:Model) =
223223

224224
| ExecuteAction action ->
225225
match action with
226+
| TagActionForBox.Ignore ->
227+
model, Cmd.none
226228
| TagActionForBox.UnknownTag ->
227229
log.Warn "Unknown Tag"
228230
model, Cmd.none

src/Server/AzureTable.fs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ let storageConnectionString =
124124
let connection = CloudStorageAccount.Parse storageConnectionString
125125

126126
let tagsTable = getTable "tags" connection
127+
let usersTable = getTable "users" connection
127128
let positionsTable = getTable "positions" connection
128129
let linksTable = getTable "links" connection
129130
let requestsTable = getTable "requests" connection
@@ -150,6 +151,13 @@ let mapTag (entity: DynamicTableEntity) : Tag =
150151
| Error msg -> failwith msg
151152
| Ok action -> action }
152153

154+
let mapUser (entity: DynamicTableEntity) : User =
155+
{ UserID = entity.RowKey
156+
SonosID = getStringProperty "SonosID" entity
157+
SpeakerType =
158+
match Decode.fromString SpeakerType.Decoder (getStringProperty "SpeakerType" entity) with
159+
| Error msg -> failwith msg
160+
| Ok action -> action }
153161

154162
let mapRequest (entity: DynamicTableEntity) : Request =
155163
{ UserID = entity.PartitionKey
@@ -232,6 +240,16 @@ let getTag (userID:string) token = task {
232240
if isNull result then return None else return Some(mapTag result)
233241
}
234242

243+
let getUser (userID:string) = task {
244+
let query = TableOperation.Retrieve("users", userID)
245+
let! r = usersTable.ExecuteAsync(query)
246+
if r.HttpStatusCode <> 200 then
247+
return None
248+
else
249+
let result = r.Result :?> DynamicTableEntity
250+
if isNull result then return None else return Some(mapUser result)
251+
}
252+
235253
let getPlayListPosition (userID:string) token = task {
236254
let query = TableOperation.Retrieve(userID, token)
237255
let! r = positionsTable.ExecuteAsync(query)

src/Server/Server.fs

Lines changed: 140 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ let uploadMusik (stream:Stream) = task {
5353
let mapBlobMusikTag (tag:Tag) = task {
5454
match tag.Action with
5555
| TagAction.PlayBlobMusik mediaIDs ->
56-
let list = System.Collections.Generic.List<_>()
56+
let list = System.Collections.Generic.List<_>()
5757
for mediaID in mediaIDs do
5858
let! sas = getSASMediaLink(mediaID.ToString())
5959
list.Add sas
@@ -109,7 +109,7 @@ let uploadEndpoint (userID:string) =
109109
let file = form.Files.[0]
110110
use stream = file.OpenReadStream()
111111
let! tagAction = uploadMusik stream
112-
let tag : Tag = {
112+
let tag : Tag = {
113113
Token = System.Guid.NewGuid().ToString()
114114
Action = tagAction
115115
Description = ""
@@ -130,14 +130,14 @@ let previousFileEndpoint (userID,token) =
130130
plug (fun next ctx -> task {
131131
let! _ = AzureTable.saveRequest userID token
132132
let! tag = AzureTable.getTag userID token
133-
let! position = AzureTable.getPlayListPosition userID token
133+
let! position = AzureTable.getPlayListPosition userID token
134134
let position = position |> Option.map (fun p -> p.Position + 1) |> Option.defaultValue 0
135135
let! _ = AzureTable.savePlayListPosition userID token position
136136
let! tag =
137137
match tag with
138138
| Some t -> mapBlobMusikTag t
139-
| _ ->
140-
let t = {
139+
| _ ->
140+
let t = {
141141
Token = token
142142
UserID = userID
143143
Action = TagAction.UnknownTag
@@ -147,7 +147,7 @@ let previousFileEndpoint (userID,token) =
147147
task { return t }
148148

149149
let! tag = mapYoutube tag
150-
let tag : TagForBox = {
150+
let tag : TagForBox = {
151151
Token = tag.Token
152152
Object = tag.Object
153153
Description = tag.Description
@@ -158,40 +158,145 @@ let previousFileEndpoint (userID,token) =
158158
})
159159
}
160160

161+
let accessToken = "0df1e468-cd6f-4038-9734-9bcf4777925b"
162+
let group = "RINCON_347E5CF009E001400:3169659583"
163+
164+
open System.Net
165+
open Microsoft.Extensions.Logging
166+
167+
let post (log:ILogger) (url:string) headers (data:string) = task {
168+
let req = HttpWebRequest.Create(url) :?> HttpWebRequest
169+
req.ProtocolVersion <- HttpVersion.Version10
170+
req.Method <- "POST"
171+
172+
for (name:string),(value:string) in headers do
173+
req.Headers.Add(name,value) |> ignore
174+
175+
let postBytes = System.Text.Encoding.UTF8.GetBytes(data)
176+
req.ContentType <- "application/json; charset=utf-8"
177+
req.ContentLength <- int64 postBytes.Length
178+
179+
try
180+
// Write data to the request
181+
use reqStream = req.GetRequestStream()
182+
do! reqStream.WriteAsync(postBytes, 0, postBytes.Length)
183+
reqStream.Close()
184+
185+
use resp = req.GetResponse()
186+
use stream = resp.GetResponseStream()
187+
use reader = new StreamReader(stream)
188+
let! html = reader.ReadToEndAsync()
189+
return html
190+
with
191+
| :? WebException as exn when not (isNull exn.Response) ->
192+
use errorResponse = exn.Response :?> HttpWebResponse
193+
use reader = new StreamReader(errorResponse.GetResponseStream())
194+
let error = reader.ReadToEnd()
195+
log.LogError(sprintf "Request to %s failed with: %s %s" url exn.Message error)
196+
return! raise exn
197+
}
198+
199+
type Session =
200+
{ ID : string }
201+
202+
static member Decoder =
203+
Decode.object (fun get ->
204+
{ ID = get.Required.Field "sessionId" Decode.string }
205+
)
206+
207+
let createOrJoinSession (log:ILogger) accessToken group = task {
208+
let headers = ["Bearer " , accessToken]
209+
let url = sprintf "https://api.ws.sonos.com/control/api/v1/groups/%s/playbackSession/joinOrCreate" group
210+
let body = """{
211+
"appId": "com.Forkmann.AudioHub",
212+
"appContext": "1a2b3c24b",
213+
"customData": "playlistid:12345"
214+
}"""
215+
216+
let! result = post log url headers body
217+
218+
match Decode.fromString Session.Decoder result with
219+
| Error msg -> return failwith msg
220+
| Ok session -> return session
221+
}
222+
223+
224+
let playStream (log:ILogger) accessToken (session:Session) (tag:Tag) = task {
225+
let headers = ["Bearer " , accessToken]
226+
let url = sprintf "https://api.ws.sonos.com/control/api/v1/playbackSessions/%s/playbackSession/loadStreamUrl" session.ID
227+
228+
match tag.Action with
229+
| TagAction.PlayMusik stream ->
230+
let body = sprintf """{
231+
"streamUrl": "%s",
232+
"playOnCompletion": true,
233+
"stationMetadata": {
234+
"name": "%s"
235+
},
236+
"itemId" : "%s"
237+
}""" stream.[0] (tag.Object + " - " + tag.Description) tag.Token
238+
239+
240+
let! _result = post log url headers body
241+
()
242+
| _ -> ()
243+
}
244+
161245
let nextFileEndpoint (userID,token) =
162246
pipeline {
163247
set_header "Content-Type" "application/json"
164248
plug (fun next ctx -> task {
165249
let! _ = AzureTable.saveRequest userID token
166-
let! tag = AzureTable.getTag userID token
167-
168-
let! position = AzureTable.getPlayListPosition userID token
169-
let position = position |> Option.map (fun p -> p.Position - 1) |> Option.defaultValue 0
170-
171-
let! tag =
172-
match tag with
173-
| Some t -> mapBlobMusikTag t
174-
| _ ->
175-
let t = {
176-
Token = token
177-
UserID = userID
178-
Action = TagAction.UnknownTag
179-
LastVerified = DateTimeOffset.MinValue
180-
Description = ""
181-
Object = "" }
182-
task { return t }
183-
184-
let! tag = mapYoutube tag
185-
let tag : TagForBox = {
186-
Token = tag.Token
187-
Object = tag.Object
188-
Description = tag.Description
189-
Action = TagActionForBox.GetFromTagAction(tag.Action,position) }
190-
191-
let! _ = AzureTable.savePlayListPosition userID token position
192-
193-
let txt = TagForBox.Encoder tag |> Encode.toString 0
194-
return! setBodyFromString txt next ctx
250+
match! AzureTable.getUser userID with
251+
| None ->
252+
return! Response.notFound ctx userID
253+
| Some user ->
254+
let! tag = AzureTable.getTag userID token
255+
256+
let! position = AzureTable.getPlayListPosition userID token
257+
let position = position |> Option.map (fun p -> p.Position - 1) |> Option.defaultValue 0
258+
259+
let! tag =
260+
match tag with
261+
| Some t -> mapBlobMusikTag t
262+
| _ ->
263+
let t = {
264+
Token = token
265+
UserID = userID
266+
Action = TagAction.UnknownTag
267+
LastVerified = DateTimeOffset.MinValue
268+
Description = ""
269+
Object = "" }
270+
task { return t }
271+
272+
let! tag = mapYoutube tag
273+
let! _ = AzureTable.savePlayListPosition userID token position
274+
275+
match user.SpeakerType with
276+
| SpeakerType.Local ->
277+
let! tag = mapYoutube tag
278+
let tag : TagForBox = {
279+
Token = tag.Token
280+
Object = tag.Object
281+
Description = tag.Description
282+
Action = TagActionForBox.GetFromTagAction(tag.Action,position) }
283+
284+
285+
let txt = TagForBox.Encoder tag |> Encode.toString 0
286+
return! setBodyFromString txt next ctx
287+
| SpeakerType.Sonos ->
288+
let logger = ctx.GetLogger "NextFile"
289+
let! session = createOrJoinSession logger accessToken group
290+
do! playStream logger accessToken session tag
291+
292+
let tag : TagForBox = {
293+
Token = tag.Token
294+
Object = tag.Object
295+
Description = tag.Description
296+
Action = TagActionForBox.Ignore }
297+
298+
let txt = TagForBox.Encoder tag |> Encode.toString 0
299+
return! setBodyFromString txt next ctx
195300
})
196301
}
197302

src/Shared/Shared.fs

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,21 +72,22 @@ type TagAction =
7272
[<RequireQualifiedAccess>]
7373
type TagActionForBox =
7474
| UnknownTag
75+
| Ignore
7576
| StopMusik
7677
| PlayMusik of string
7778

7879
static member GetFromTagAction(action:TagAction,position) =
7980
match action with
8081
| TagAction.StopMusik -> TagActionForBox.StopMusik
8182
| TagAction.UnknownTag -> TagActionForBox.UnknownTag
82-
| TagAction.PlayMusik urls ->
83+
| TagAction.PlayMusik urls ->
8384
if Array.isEmpty urls then
8485
TagActionForBox.StopMusik
8586
else
8687
let pos = Math.Abs(position % urls.Length)
8788
TagActionForBox.PlayMusik urls.[pos]
8889
| _ -> failwithf "Can't convert %A" action
89-
90+
9091
static member Encoder (action : TagActionForBox) =
9192
match action with
9293
| TagActionForBox.UnknownTag ->
@@ -97,6 +98,10 @@ type TagActionForBox =
9798
Encode.object [
9899
"StopMusik", Encode.nil
99100
]
101+
| TagActionForBox.Ignore ->
102+
Encode.object [
103+
"Ignore", Encode.nil
104+
]
100105
| TagActionForBox.PlayMusik url ->
101106
Encode.object [
102107
"PlayMusik", Encode.string url
@@ -106,6 +111,7 @@ type TagActionForBox =
106111
Decode.oneOf [
107112
Decode.field "UnknownTag" (Decode.succeed TagActionForBox.UnknownTag)
108113
Decode.field "StopMusik" (Decode.succeed TagActionForBox.StopMusik)
114+
Decode.field "Ignore" (Decode.succeed TagActionForBox.Ignore)
109115
Decode.field "PlayMusik" Decode.string |> Decode.map TagActionForBox.PlayMusik
110116
]
111117

@@ -138,12 +144,52 @@ type Tag =
138144
Object = get.Required.Field "Object" Decode.string
139145
Description = get.Required.Field "Description" Decode.string
140146
UserID = get.Required.Field "UserID" Decode.string
141-
LastVerified =
142-
get.Optional.Field "LastVerified" Decode.datetimeOffset
147+
LastVerified =
148+
get.Optional.Field "LastVerified" Decode.datetimeOffset
143149
|> Option.defaultValue DateTimeOffset.MinValue
144150
Action = get.Required.Field "Action" TagAction.Decoder }
145151
)
146152

153+
[<RequireQualifiedAccess>]
154+
type SpeakerType =
155+
| Local
156+
| Sonos
157+
158+
static member Encoder (action : SpeakerType) =
159+
match action with
160+
| Local ->
161+
Encode.object [
162+
"Local", Encode.nil
163+
]
164+
| Sonos ->
165+
Encode.object [
166+
"Sonos", Encode.nil
167+
]
168+
169+
static member Decoder =
170+
Decode.oneOf [
171+
Decode.field "Local" (Decode.succeed SpeakerType.Local)
172+
Decode.field "Sonos" (Decode.succeed SpeakerType.Sonos)
173+
]
174+
175+
type User =
176+
{ UserID : string
177+
SpeakerType : SpeakerType
178+
SonosID : string }
179+
180+
static member Encoder (user : User) =
181+
Encode.object [
182+
"UserID", Encode.string user.UserID
183+
"SpeakerType", SpeakerType.Encoder user.SpeakerType
184+
"SonosID", Encode.string user.SonosID
185+
]
186+
static member Decoder =
187+
Decode.object (fun get ->
188+
{ UserID = get.Required.Field "UserID" Decode.string
189+
SpeakerType = get.Required.Field "SpeakerType" SpeakerType.Decoder
190+
SonosID = get.Required.Field "SonosID" Decode.string }
191+
)
192+
147193
type Request =
148194
{ Token : string
149195
Timestamp : DateTimeOffset

0 commit comments

Comments
 (0)