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.

Refresh Google Drive access token on url transform

+266 -162
-21
nix/shell.nix
··· 9 9 rustc = rust; 10 10 }; 11 11 12 - # Trying to fix an issue with `watchexec` on MacOS 13 - # https://github.com/h4llow3En/mac-notification-sys/issues/28 14 - # Nix uses old Apple SDKs? Not sure, but definitely a Nix issue. 15 - watchexec = rustPlatform.buildRustPackage rec { 16 - pname = "watchexec"; 17 - version = "1.20.4"; 18 - 19 - cargoSha256 = "sha256-YM+Zm3wFp3Lsx5LmyjGwZywV/SZjriL6JMDO1l0tNf4="; 20 - 21 - src = fetchFromGitHub { 22 - owner = pname; 23 - repo = pname; 24 - rev = "cli-v${version}"; 25 - sha256 = "sha256-se3iqz+qjwf71wvHQhCWYryEdUc+kY0Q0ZTg4i1ayNI="; 26 - }; 27 - 28 - nativeBuildInputs = lib.optionals 29 - stdenv.isDarwin 30 - (with darwin.apple_sdk_11_0.frameworks; [ Cocoa Foundation ]); 31 - }; 32 - 33 12 # Wraps 34 13 # ----- 35 14 # Inspired by https://www.tweag.io/blog/2022-06-02-haskell-stack-nix-shell/
+4 -3
src/Applications/Brain/Sources/Processing/Steps.elm
··· 80 80 takePrepareStep : Context -> String -> Time.Posix -> Cmd Brain.Msg 81 81 takePrepareStep context response currentTime = 82 82 context 83 - |> handlePreparationResponse response 83 + |> handlePreparationResponse response currentTime 84 84 |> intoPreparationCommands currentTime 85 85 86 86 ··· 149 149 makeTree context currentTime 150 150 151 151 152 - handlePreparationResponse : String -> Context -> Context 153 - handlePreparationResponse response context = 152 + handlePreparationResponse : String -> Time.Posix -> Context -> Context 153 + handlePreparationResponse response currentTime context = 154 154 let 155 155 answer = 156 156 Services.parsePreparationResponse 157 157 context.source.service 158 158 response 159 + currentTime 159 160 context.source.data 160 161 context.preparationMarker 161 162
+12 -11
src/Javascript/Brain/artwork.js
··· 10 10 const REJECT = () => Promise.reject("No artwork found") 11 11 12 12 13 - export function find(prep) { 13 + export function find(prep, app) { 14 14 return findUsingTags(prep) 15 15 .then(a => a ? a : findUsingMusicBrainz(prep)) 16 16 .then(a => a ? a : findUsingLastFm(prep)) ··· 28 28 // 1. TAGS 29 29 30 30 31 - function findUsingTags(prep) { 31 + function findUsingTags(prep, app) { 32 32 return Promise.all( 33 - [ transformUrl(prep.trackHeadUrl) 34 - , transformUrl(prep.trackGetUrl) 33 + [ 34 + transformUrl(prep.trackHeadUrl, app), 35 + transformUrl(prep.trackGetUrl, app) 35 36 ] 36 37 37 38 ).then(([ headUrl, getUrl ]) => processing.getTags( ··· 55 56 56 57 function findUsingMusicBrainz(prep) { 57 58 const parts = decodeCacheKey(prep.cacheKey).split(" --- ") 58 - const artist = parts[0] 59 - const album = parts[1] || parts[0] 59 + const artist = parts[ 0 ] 60 + const album = parts[ 1 ] || parts[ 0 ] 60 61 61 62 const query = `release:"${album}"` + (prep.variousArtists === "t" ? `` : ` AND artist:"${artist}"`) 62 63 const encodedQuery = encodeURIComponent(query) ··· 68 69 69 70 70 71 function musicBrainzCover(remainingReleases) { 71 - const release = remainingReleases[0] 72 + const release = remainingReleases[ 0 ] 72 73 if (!release) return null 73 74 74 75 return fetch( ··· 99 100 100 101 101 102 function lastFmCover(remainingMatches) { 102 - const album = remainingMatches[0] 103 - const url = album ? album.image[album.image.length - 1]["#text"] : null 103 + const album = remainingMatches[ 0 ] 104 + const url = album ? album.image[ album.image.length - 1 ][ "#text" ] : null 104 105 105 106 return url && url !== "" 106 107 ? fetch(url) 107 - .then(r => r.blob()) 108 - .catch(_ => lastFmCover(remainingMatches.slice(1))) 108 + .then(r => r.blob()) 109 + .catch(_ => lastFmCover(remainingMatches.slice(1))) 109 110 : album && lastFmCover(remainingMatches.slice(1)) 110 111 }
+34 -24
src/Javascript/Brain/index.js
··· 26 26 .substr(1) 27 27 .split("&") 28 28 .reduce((acc, flag) => { 29 - const [k, v] = flag.split("=") 30 - return { ...acc, [k]: v } 29 + const [ k, v ] = flag.split("=") 30 + return { ...acc, [ k ]: v } 31 31 }, {}) 32 32 33 33 ··· 56 56 } 57 57 58 58 59 - function handleAction(action, data) { switch (action) { 60 - case "DOWNLOAD_ARTWORK": return downloadArtwork(data) 61 - }} 59 + function handleAction(action, data) { 60 + switch (action) { 61 + case "DOWNLOAD_ARTWORK": return downloadArtwork(data) 62 + } 63 + } 62 64 63 65 64 66 ··· 67 69 68 70 app.ports.removeCache.subscribe(event => { 69 71 removeCache(event.tag) 70 - .catch( reportError(app, event) ) 72 + .catch(reportError(app, event)) 71 73 }) 72 74 73 75 ··· 77 79 : event.tag 78 80 79 81 fromCache(key) 80 - .then( sendData(app, event) ) 81 - .catch( reportError(app, event) ) 82 + .then(sendData(app, event)) 83 + .catch(reportError(app, event)) 82 84 }) 83 85 84 86 ··· 90 92 toCache(key, event.data.data || event.data) 91 93 .then( 92 94 event.tag === "AUTH_ANONYMOUS" 93 - ? storageCallback(app, event) 94 - : identity 95 + ? storageCallback(app, event) 96 + : identity 95 97 ) 96 - .catch( reportError(app, event) ) 98 + .catch(reportError(app, event)) 97 99 }) 98 100 99 101 ··· 105 107 106 108 107 109 function downloadArtwork(list) { 108 - const exe = !artworkQueue[0] 110 + const exe = !artworkQueue[ 0 ] 109 111 artworkQueue = artworkQueue.concat(list) 110 112 if (exe) shiftArtworkQueue() 111 113 } ··· 127 129 128 130 app.ports.provideArtworkTrackUrls.subscribe(prep => { 129 131 artwork 130 - .find(prep) 132 + .find(prep, app) 131 133 .then(blob => { 132 134 const url = URL.createObjectURL(blob) 133 135 ··· 139 141 140 142 return toCache(`coverCache.${prep.cacheKey}`, blob) 141 143 }) 142 - .catch(_ => { 143 - // Indicate that we've tried to find artwork, 144 - // so that we don't try to find it each time we launch the app. 145 - return toCache(`coverCache.${prep.cacheKey}`, "TRIED") 144 + .catch(err => { 145 + if (err === "No artwork found") { 146 + // Indicate that we've tried to find artwork, 147 + // so that we don't try to find it each time we launch the app. 148 + return toCache(`coverCache.${prep.cacheKey}`, "TRIED") 149 + 150 + } else { 151 + // Something went wrong 152 + reportError(app, { tag: "REPORT_ERROR" })(err) 153 + 154 + } 146 155 }) 147 156 .finally(shiftArtworkQueue) 148 157 }) ··· 159 168 160 169 ).catch( 161 170 _ => reportError 162 - ({ tag: "REMOVE_TRACKS_FROM_CACHE" }) 171 + (app, { tag: "REMOVE_TRACKS_FROM_CACHE" }) 163 172 ("Failed to remove tracks from cache") 164 173 165 174 ) ··· 168 177 169 178 app.ports.storeTracksInCache.subscribe(list => { 170 179 list.reduce( 171 - (acc, item) => { return acc 172 - .then(_ => fetch(item.url)) 173 - .then(r => r.blob()) 174 - .then(b => db.setInIndex({ key: item.trackId, data: b, store: db.storeNames.tracks })) 180 + (acc, item) => { 181 + return acc 182 + .then(_ => fetch(item.url)) 183 + .then(r => r.blob()) 184 + .then(b => db.setInIndex({ key: item.trackId, data: b, store: db.storeNames.tracks })) 175 185 }, 176 186 Promise.resolve() 177 187 ··· 245 255 // ---- 246 256 247 257 app.ports.requestTags.subscribe(context => { 248 - processing.processContext(context).then(newContext => { 258 + processing.processContext(context, app).then(newContext => { 249 259 app.ports.receiveTags.send(newContext) 250 260 }) 251 261 }) 252 262 253 263 254 264 app.ports.syncTags.subscribe(context => { 255 - processing.processContext(context).then(newContext => { 265 + processing.processContext(context, app).then(newContext => { 256 266 app.ports.replaceTags.send(newContext) 257 267 }) 258 268 })
+24 -24
src/Javascript/audio-engine.js
··· 15 15 16 16 17 17 const IS_SAFARI = !!navigator.platform.match(/iPhone|iPod|iPad/) || 18 - navigator.vendor === "Apple Computer, Inc." 18 + navigator.vendor === "Apple Computer, Inc." 19 19 20 20 21 21 ··· 38 38 let c 39 39 let styles = 40 40 [ "height: 0" 41 - , "width: 0" 42 - , "visibility: hidden" 43 - , "pointer-events: none" 41 + , "width: 0" 42 + , "visibility: hidden" 43 + , "pointer-events: none" 44 44 ] 45 45 46 46 c = document.createElement("div") ··· 117 117 // initial promise 118 118 const initialPromise = queueItem.isCached 119 119 ? db.getFromIndex({ key: queueItem.trackId, store: db.storeNames.tracks }).then(blobUrl) 120 - : transformUrl(queueItem.url) 120 + : transformUrl(queueItem.url, orchestrion.app) 121 121 122 122 // find or create audio node 123 123 let audioNode ··· 215 215 ) 216 216 217 217 // audio element remains valid for 2 hours 218 - transformUrl(queueItem.url).then(url => { 218 + transformUrl(queueItem.url, orchestrion.app).then(url => { 219 219 const queueItemWithTransformedUrl = 220 220 Object.assign({}, queueItem, { url: url }) 221 221 ··· 285 285 } 286 286 287 287 288 - function showNetworkErrorNotification() { 289 - if (showedNoNetworkError) return 290 - showedNoNetworkError = true 291 - this.app.ports.showErrorNotification.send( 292 - navigator.onLine 293 - ? "I can't play this track because of a network error. I'll try to reconnect." 294 - : "I can't play this track because we're offline. I'll try to reconnect." 295 - ) 296 - } 288 + function showNetworkErrorNotification() { 289 + if (showedNoNetworkError) return 290 + showedNoNetworkError = true 291 + this.app.ports.showErrorNotification.send( 292 + navigator.onLine 293 + ? "I can't play this track because of a network error. I'll try to reconnect." 294 + : "I can't play this track because we're offline. I'll try to reconnect." 295 + ) 296 + } 297 297 298 298 299 - function showUnsupportedSrcErrorNotification() { 300 - this.app.ports.showErrorNotification.send( 301 - "__I can't play this track because your browser didn't recognize it.__ Try checking your developer console for a warning to find out why." 302 - ) 303 - } 299 + function showUnsupportedSrcErrorNotification() { 300 + this.app.ports.showErrorNotification.send( 301 + "__I can't play this track because your browser didn't recognize it.__ Try checking your developer console for a warning to find out why." 302 + ) 303 + } 304 304 305 305 306 306 function audioStalledEvent(event, notifyAppImmediately) { ··· 426 426 !node || 427 427 node.getAttribute("data-preload") === "t" 428 428 ) 429 - ? false 430 - : orchestrion.activeQueueItem.trackId === audioElementTrackId(node) 429 + ? false 430 + : orchestrion.activeQueueItem.trackId === audioElementTrackId(node) 431 431 } 432 432 433 433 ··· 492 492 let artwork = [] 493 493 494 494 if (maybeArtwork && typeof maybeArtwork !== "string") { 495 - artwork = [{ 495 + artwork = [ { 496 496 src: URL.createObjectURL(maybeArtwork), 497 497 type: maybeArtwork.type 498 - }] 498 + } ] 499 499 } 500 500 501 501 navigator.mediaSession.metadata = new MediaMetadata({
+56 -41
src/Javascript/index.js
··· 1 1 // 2 - // Elm loader 3 2 // | (• ◡•)| (❍ᴥ❍ʋ) 4 3 // 5 4 // The bit where we launch the Elm app, ··· 17 16 import * as db from "./indexed-db" 18 17 import { version } from '../../package.json' 19 18 import { WEBNATIVE_STAGING_ENV, WEBNATIVE_STAGING_MODE, debounce, fileExtension } from "./common" 19 + import { transformUrl } from "./urls" 20 20 21 21 22 22 ··· 35 35 location.href = location.href.replace("http://", "https://") 36 36 failure("Just a moment, redirecting to HTTPS.") 37 37 38 - // Not a secure context 38 + // Not a secure context 39 39 } else if (!self.isSecureContext) { 40 40 failure(` 41 41 This app only works on a <a class="underline" target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#When_is_a_context_considered_secure">secure context</a>, HTTPS & localhost, and modern browsers. 42 42 `) 43 43 44 - // Service worker 44 + // Service worker 45 45 } else if ("serviceWorker" in navigator) { 46 46 window.addEventListener("load", () => { 47 47 navigator.serviceWorker ··· 193 193 } 194 194 195 195 196 - function handleAction(action, data, _ports) { switch (action) { 197 - case "DOWNLOAD_TRACKS": return downloadTracks(data) 198 - case "FINISHED_DOWNLOADING_ARTWORK": return finishedDownloadingArtwork() 199 - }} 196 + function handleAction(action, data, _ports) { 197 + switch (action) { 198 + case "DOWNLOAD_TRACKS": return downloadTracks(data) 199 + case "FINISHED_DOWNLOADING_ARTWORK": return finishedDownloadingArtwork() 200 + } 201 + } 200 202 201 203 202 204 ··· 257 259 // 🎵 258 260 if (item) { 259 261 const coverPrep = { 260 - cacheKey: btoa(unescape(encodeURIComponent(item.trackTags.artist + " --- " + item.trackTags.album))), 261 - trackFilename: item.trackPath.split("/").reverse()[0], 262 - trackPath: item.trackPath, 263 - trackSourceId: item.sourceId, 262 + cacheKey: btoa(unescape(encodeURIComponent(item.trackTags.artist + " --- " + item.trackTags.album))), 263 + trackFilename: item.trackPath.split("/").reverse()[ 0 ], 264 + trackPath: item.trackPath, 265 + trackSourceId: item.sourceId, 264 266 variousArtists: "f" 265 267 } 266 268 ··· 285 287 }) 286 288 }) 287 289 288 - // ✋ 290 + // ✋ 289 291 } else { 290 292 app.ports.setAudioIsPlaying.send(false) 291 293 app.ports.setAudioPosition.send(0) ··· 388 390 const color = { r: 0, g: 0, b: 0 } 389 391 390 392 for (let i = 0, l = imageData.data.length; i < l; i += 4) { 391 - color.r += imageData.data[i] 392 - color.g += imageData.data[i + 1] 393 - color.b += imageData.data[i + 2] 393 + color.r += imageData.data[ i ] 394 + color.g += imageData.data[ i + 1 ] 395 + color.b += imageData.data[ i + 2 ] 394 396 } 395 397 396 398 color.r = Math.floor(color.r / (imageData.data.length / 4)) ··· 432 434 // --------- 433 435 434 436 wire.clipboard = () => { 435 - app.ports.copyToClipboard.subscribe(copyToClipboard) 437 + app.ports.copyToClipboard.subscribe(text => { 438 + // TODO: Find a better solution for this 439 + const adjustedText = (() => { 440 + if (text.startsWith("dropbox://")) { 441 + return transformUrl(text) 442 + } else if (text.startsWith("google://")) { 443 + return transformUrl(text) 444 + } else { 445 + return text 446 + 447 + } 448 + })() 449 + 450 + copyToClipboard(copyToClipboard) 451 + }) 436 452 } 437 453 438 454 439 455 function copyToClipboard(text) { 440 - 441 456 // Insert a textarea element 442 457 const el = document.createElement("textarea") 443 458 ··· 465 480 document.getSelection().removeAllRanges() 466 481 document.getSelection().addRange(selected) 467 482 } 468 - 469 483 } 470 484 471 485 ··· 491 505 const item = orchestrion.activeQueueItem 492 506 493 507 if (item && orchestrion.coverPrep && key === orchestrion.coverPrep.key && url) { 494 - let artwork = [{ src: url }] 508 + let artwork = [ { src: url } ] 495 509 496 510 if (typeof url !== "string") { 497 - artwork = [{ 511 + artwork = [ { 498 512 src: URL.createObjectURL(url), 499 513 type: url.type 500 - }] 514 + } ] 501 515 } 502 516 503 517 navigator.mediaSession.metadata = new MediaMetadata({ ··· 524 538 if (!nodes.length) return; 525 539 526 540 const coverPrepList = nodes.map(node => ({ 527 - cacheKey: node.getAttribute("data-key"), 528 - trackFilename: node.getAttribute("data-filename"), 529 - trackPath: node.getAttribute("data-path"), 530 - trackSourceId: node.getAttribute("data-source-id"), 541 + cacheKey: node.getAttribute("data-key"), 542 + trackFilename: node.getAttribute("data-filename"), 543 + trackPath: node.getAttribute("data-path"), 544 + trackSourceId: node.getAttribute("data-source-id"), 531 545 variousArtists: node.getAttribute("data-various-artists") 532 546 })) 533 547 ··· 566 580 const cacheKey = key.slice(11) 567 581 568 582 if (blob && typeof blob !== "string") { 569 - cache[cacheKey] = URL.createObjectURL(blob) 583 + cache[ cacheKey ] = URL.createObjectURL(blob) 570 584 } 571 585 572 586 return cache ··· 618 632 const folder = zip.folder("Diffuse - " + group.name) 619 633 620 634 return group.tracks.reduce( 621 - (acc, track) => { return acc 622 - .then(_ => fetch(track.url)) 623 - .then(r => { 624 - const mimeType = r.headers.get("content-type") 625 - const fileExt = fileExtension(mimeType) || "unknown" 635 + (acc, track) => { 636 + return acc 637 + .then(_ => fetch(track.url)) 638 + .then(r => { 639 + const mimeType = r.headers.get("content-type") 640 + const fileExt = fileExtension(mimeType) || "unknown" 626 641 627 - return r.blob().then( 628 - b => folder.file(track.filename + "." + fileExt, b) 629 - ) 630 - }) 642 + return r.blob().then( 643 + b => folder.file(track.filename + "." + fileExt, b) 644 + ) 645 + }) 631 646 }, 632 647 Promise.resolve() 633 648 ··· 863 878 // Simulate `pointerenter` and `pointerleave` event for non-touch devices 864 879 if (!self.PointerEvent) { 865 880 document.addEventListener("mouseover", event => { 866 - const section = document.body.querySelector("section") 867 - const isDragging = section && section.classList.contains("dragging-something") 868 - const node = isDragging && document.elementFromPoint(event.clientX, event.clientY) 881 + const section = document.body.querySelector("section") 882 + const isDragging = section && section.classList.contains("dragging-something") 883 + const node = isDragging && document.elementFromPoint(event.clientX, event.clientY) 869 884 870 885 if (node && node != enteredElement) { 871 886 enteredElement && enteredElement.dispatchEvent(mousePointerEvent("pointerleave", event)) ··· 878 893 879 894 // Simulate `pointerenter` and `pointerleave` event for touch devices 880 895 document.body.addEventListener("touchmove", event => { 881 - const section = document.body.querySelector("section") 882 - const isDragging = section && section.classList.contains("dragging-something") 896 + const section = document.body.querySelector("section") 897 + const isDragging = section && section.classList.contains("dragging-something") 883 898 884 - let touch = event.touches[0] 899 + let touch = event.touches[ 0 ] 885 900 let node 886 901 887 902 if (isDragging && touch) {
+9 -9
src/Javascript/processing.js
··· 15 15 // Contexts 16 16 // -------- 17 17 18 - export function processContext(context) { 18 + export function processContext(context, app) { 19 19 const initialPromise = Promise.resolve([]) 20 20 21 21 return context.urlsForTags.reduce((accumulator, urls, idx) => { 22 22 return accumulator.then(col => { 23 23 const filename = context 24 - .receivedFilePaths[idx] 24 + .receivedFilePaths[ idx ] 25 25 .split("/") 26 - .reverse()[0] 26 + .reverse()[ 0 ] 27 27 28 28 return Promise.all([ 29 - transformUrl(urls.headUrl), 30 - transformUrl(urls.getUrl) 29 + transformUrl(urls.headUrl, app), 30 + transformUrl(urls.getUrl, app) 31 31 32 - ]).then(([headUrl, getUrl]) => { 32 + ]).then(([ headUrl, getUrl ]) => { 33 33 return getTags(headUrl, getUrl, filename, { skipCovers: true }) 34 34 35 35 }).then(r => { ··· 63 63 64 64 export function getTags(headUrl, getUrl, filename, options) { 65 65 const fileExtMatch = filename.match(/\.(\w+)$/) 66 - const fileExt = fileExtMatch && fileExtMatch[1] 66 + const fileExt = fileExtMatch && fileExtMatch[ 1 ] 67 67 68 68 const overrideContentType = ( 69 69 getUrl.includes("googleapis.com") || ··· 109 109 album: tags.album && tags.album.length ? tags.album : "Unknown", 110 110 artist: artist || "Unknown", 111 111 title: title ? title : (artist ? "Unknown" : filename.replace(/\.\w+$/, "")), 112 - genre: (tags.genre && tags.genre[0]) || null, 112 + genre: (tags.genre && tags.genre[ 0 ]) || null, 113 113 year: tags.year || null, 114 - picture: tags.picture ? tags.picture[0] : null 114 + picture: tags.picture ? tags.picture[ 0 ] : null 115 115 } 116 116 } 117 117
+79 -8
src/Javascript/urls.js
··· 5 5 // Some URLs are special you know. 6 6 7 7 8 - export function transformUrl(url) { 8 + const EXPIRED_ACCESS_TOKENS = { 9 + GOOGLE: {} 10 + } 11 + 12 + 13 + export async function transformUrl(url, app) { 9 14 const parts = url.split("://") 10 15 11 - switch (parts[0]) { 16 + switch (parts[ 0 ]) { 12 17 13 18 case "dropbox": { 14 - const dropboxBits = parts[1].split("@") 15 - const accessToken = dropboxBits[0] 16 - const filePath = dropboxBits[1] 19 + const dropboxBits = parts[ 1 ].split("@") 20 + const accessToken = dropboxBits[ 0 ] 21 + const filePath = dropboxBits[ 1 ] 17 22 18 23 return fetch( 19 24 "https://api.dropboxapi.com/2/files/get_temporary_link", 20 - { method: "POST" 21 - , body: JSON.stringify({ path: filePath }) 22 - , headers: new Headers({ 25 + { 26 + method: "POST" 27 + , body: JSON.stringify({ path: filePath }) 28 + , headers: new Headers({ 23 29 "Authorization": "Bearer " + accessToken, 24 30 "Content-Type": "application/json" 25 31 }) ··· 31 37 ) 32 38 } 33 39 40 + case "google": { 41 + let finalAccessToken 42 + 43 + const googleBits = parts[ 1 ].split("@") 44 + const [ accessToken, expiresAtString, refreshToken, clientId, clientSecret ] = googleBits[ 0 ].split(":") 45 + const fileId = googleBits[ 1 ] 46 + 47 + // Unix timestamp in milliseconds 48 + const in15minutes = Date.now() + 15 * 60 * 1000 49 + const expiresAt = parseInt(expiresAtString, 10) 50 + const isAlmostExpired = expiresAt <= in15minutes 51 + 52 + if (EXPIRED_ACCESS_TOKENS.GOOGLE[ accessToken ]) { 53 + const replacement = EXPIRED_ACCESS_TOKENS.GOOGLE[ accessToken ] 54 + 55 + if (replacement.newExpiresAt <= in15minutes) { 56 + finalAccessToken = await refreshGoogleAccessToken({ 57 + clientId, clientSecret, refreshToken, oldToken: accessToken 58 + }) 59 + } else { 60 + finalAccessToken = replacement.newToken 61 + } 62 + 63 + } else if (isAlmostExpired) { 64 + finalAccessToken = await refreshGoogleAccessToken({ 65 + clientId, clientSecret, refreshToken, oldToken: accessToken 66 + }) 67 + 68 + } else { 69 + finalAccessToken = accessToken 70 + 71 + } 72 + 73 + return Promise.resolve( 74 + `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}?alt=media&bearer_token=${encodeURIComponent(finalAccessToken)}` 75 + ) 76 + } 77 + 34 78 default: 35 79 return Promise.resolve(url) 36 80 37 81 } 38 82 } 83 + 84 + 85 + 86 + // GOOGLE 87 + 88 + 89 + async function refreshGoogleAccessToken({ clientId, clientSecret, oldToken, refreshToken }) { 90 + console.log("🔐 Refreshing Google Drive access token") 91 + 92 + const url = new URL("https://www.googleapis.com/oauth2/v4/token") 93 + 94 + url.searchParams.set("client_id", clientId) 95 + url.searchParams.set("client_secret", clientSecret) 96 + url.searchParams.set("refresh_token", refreshToken) 97 + url.searchParams.set("grant_type", "refresh_token") 98 + 99 + const serverResponse = await fetch(url, { method: "POST" }).then(r => r.json()) 100 + 101 + EXPIRED_ACCESS_TOKENS.GOOGLE[ oldToken ] = { 102 + oldToken, 103 + newToken: serverResponse.access_token, 104 + newExpiresAt: Date.now() + (serverResponse.expires_in * 1000), 105 + refreshToken 106 + } 107 + 108 + return serverResponse.access_token 109 + }
+1 -1
src/Library/Sources/Services.elm
··· 139 139 WebDav.parseErrorResponse 140 140 141 141 142 - parsePreparationResponse : Service -> String -> SourceData -> Marker -> PrepationAnswer Marker 142 + parsePreparationResponse : Service -> String -> Time.Posix -> SourceData -> Marker -> PrepationAnswer Marker 143 143 parsePreparationResponse service = 144 144 case service of 145 145 AmazonS3 ->
+1 -1
src/Library/Sources/Services/AmazonS3.elm
··· 146 146 147 147 {-| Re-export parser functions. 148 148 -} 149 - parsePreparationResponse : String -> SourceData -> Marker -> PrepationAnswer Marker 149 + parsePreparationResponse : String -> Time.Posix -> SourceData -> Marker -> PrepationAnswer Marker 150 150 parsePreparationResponse = 151 151 noPrep 152 152
+1 -1
src/Library/Sources/Services/AzureBlob.elm
··· 124 124 125 125 {-| Re-export parser functions. 126 126 -} 127 - parsePreparationResponse : String -> SourceData -> Marker -> PrepationAnswer Marker 127 + parsePreparationResponse : String -> Time.Posix -> SourceData -> Marker -> PrepationAnswer Marker 128 128 parsePreparationResponse = 129 129 noPrep 130 130
+1 -1
src/Library/Sources/Services/AzureFile.elm
··· 128 128 129 129 {-| Re-export parser functions. 130 130 -} 131 - parsePreparationResponse : String -> SourceData -> Marker -> PrepationAnswer Marker 131 + parsePreparationResponse : String -> Time.Posix -> SourceData -> Marker -> PrepationAnswer Marker 132 132 parsePreparationResponse = 133 133 noPrep 134 134
+3 -2
src/Library/Sources/Services/Common.elm
··· 3 3 import Sources exposing (..) 4 4 import Sources.Processing exposing (..) 5 5 import String.Ext as String 6 + import Time 6 7 7 8 8 9 ··· 43 44 -- PARSING 44 45 45 46 46 - noPrep : String -> SourceData -> Marker -> PrepationAnswer Marker 47 - noPrep _ srcData _ = 47 + noPrep : String -> Time.Posix -> SourceData -> Marker -> PrepationAnswer Marker 48 + noPrep _ _ srcData _ = 48 49 { sourceData = srcData, marker = TheEnd }
+1 -1
src/Library/Sources/Services/Dropbox.elm
··· 187 187 188 188 {-| Re-export parser functions. 189 189 -} 190 - parsePreparationResponse : String -> SourceData -> Marker -> PrepationAnswer Marker 190 + parsePreparationResponse : String -> Time.Posix -> SourceData -> Marker -> PrepationAnswer Marker 191 191 parsePreparationResponse = 192 192 noPrep 193 193
+19 -8
src/Library/Sources/Services/Google.elm
··· 250 250 251 251 {-| Re-export parser functions. 252 252 -} 253 - parsePreparationResponse : String -> SourceData -> Marker -> PrepationAnswer Marker 253 + parsePreparationResponse : String -> Time.Posix -> SourceData -> Marker -> PrepationAnswer Marker 254 254 parsePreparationResponse = 255 255 Parser.parsePreparationResponse 256 256 ··· 289 289 290 290 -} 291 291 makeTrackUrl : Time.Posix -> SourceData -> HttpMethod -> String -> String 292 - makeTrackUrl _ srcData _ path = 292 + makeTrackUrl currentTime srcData _ path = 293 293 let 294 294 file = 295 295 String.Path.file path ··· 300 300 |> List.head 301 301 |> Maybe.withDefault file 302 302 303 - accessToken = 304 - Dict.fetch "accessToken" "" srcData 303 + now = 304 + Time.posixToMillis currentTime 305 + 306 + expiresAt = 307 + Dict.fetch "expiresAt" (String.fromInt now) srcData 305 308 in 306 309 String.concat 307 - [ "https://www.googleapis.com/drive/v3/files/" 308 - , Url.percentEncode fileId 309 - , "?alt=media&bearer_token=" 310 - , Url.percentEncode accessToken 310 + [ "google://" 311 + , Dict.fetch "accessToken" "" srcData 312 + , ":" 313 + , expiresAt 314 + , ":" 315 + , Dict.fetch "refreshToken" "" srcData 316 + , ":" 317 + , Dict.fetch "clientId" "" srcData 318 + , ":" 319 + , Dict.fetch "clientSecret" "" srcData 320 + , "@" 321 + , fileId 311 322 ]
+16 -2
src/Library/Sources/Services/Google/Parser.elm
··· 9 9 import Sources.Processing exposing (Marker(..), PrepationAnswer, TreeAnswer) 10 10 import Sources.Services.Google.Marker as Marker 11 11 import String.Path 12 + import Time 12 13 13 14 14 15 15 16 -- PREPARATION 16 17 17 18 18 - parsePreparationResponse : String -> SourceData -> Marker -> PrepationAnswer Marker 19 - parsePreparationResponse response srcData _ = 19 + parsePreparationResponse : String -> Time.Posix -> SourceData -> Marker -> PrepationAnswer Marker 20 + parsePreparationResponse response currentTimePosix srcData _ = 20 21 let 21 22 newAccessToken = 22 23 response 23 24 |> decodeString (field "access_token" string) 24 25 |> Result.withDefault "" 25 26 27 + currentTime = 28 + -- Current time in milliseconds 29 + Time.posixToMillis currentTimePosix 30 + 31 + expiresAt = 32 + -- Unix timestamp in milliseconds 33 + response 34 + |> decodeString (field "expires_in" int) 35 + -- time in seconds 36 + |> Result.withDefault 2500 37 + |> (\s -> currentTime + s * 1000) 38 + 26 39 maybeRefreshToken = 27 40 response 28 41 |> decodeString (maybe <| field "refresh_token" string) ··· 39 52 in 40 53 srcData 41 54 |> Dict.insert "accessToken" newAccessToken 55 + |> Dict.insert "expiresAt" (String.fromInt expiresAt) 42 56 |> refreshTokenUpdater 43 57 |> Dict.remove "authCode" 44 58 |> (\s -> { sourceData = s, marker = TheEnd })
+1 -1
src/Library/Sources/Services/Ipfs.elm
··· 209 209 210 210 {-| Re-export parser functions. 211 211 -} 212 - parsePreparationResponse : String -> SourceData -> Marker -> PrepationAnswer Marker 212 + parsePreparationResponse : String -> Time.Posix -> SourceData -> Marker -> PrepationAnswer Marker 213 213 parsePreparationResponse = 214 214 Parser.parseDnsLookup 215 215
+3 -2
src/Library/Sources/Services/Ipfs/Parser.elm
··· 7 7 import Sources.Processing exposing (Marker(..), PrepationAnswer, TreeAnswer) 8 8 import Sources.Services.Ipfs.Marker as Marker 9 9 import String.Ext as String 10 + import Time 10 11 11 12 12 13 13 14 -- PREPARATION 14 15 15 16 16 - parseDnsLookup : String -> SourceData -> Marker -> PrepationAnswer Marker 17 - parseDnsLookup response srcData _ = 17 + parseDnsLookup : String -> Time.Posix -> SourceData -> Marker -> PrepationAnswer Marker 18 + parseDnsLookup response _ srcData _ = 18 19 case decodeString dnsResultDecoder response of 19 20 Ok path -> 20 21 srcData
+1 -1
src/Library/Sources/Services/WebDav.elm
··· 130 130 131 131 {-| Re-export parser functions. 132 132 -} 133 - parsePreparationResponse : String -> SourceData -> Marker -> PrepationAnswer Marker 133 + parsePreparationResponse : String -> Time.Posix -> SourceData -> Marker -> PrepationAnswer Marker 134 134 parsePreparationResponse = 135 135 noPrep 136 136