From bd160cf61853f46d724c3522ec7b01fa3145be18 Mon Sep 17 00:00:00 2001 From: JoshuaDoes Date: Tue, 11 Jul 2023 15:58:57 +0000 Subject: [PATCH] Initial commit (force-pushed) --- .gitignore | 25 + README.md | 77 ++ builders.go | 149 +++ css/audioplayer.css | 23 + css/libremedia.css | 267 +++++ favicon.ico | Bin 0 -> 67646 bytes go.mod | 45 + go.sum | 189 ++++ img/flaticon-freepik-singer.png | Bin 0 -> 22234 bytes img/icons8-download-96.png | Bin 0 -> 1338 bytes img/icons8-play-96.png | Bin 0 -> 1091 bytes img/loading.gif | Bin 0 -> 15100 bytes index.html | 48 + js/FileSaver.min.js | 3 + js/jszip-utils.min.js | 1 + js/jszip.min.js | 13 + js/libremedia/audioplayer.js | 108 ++ js/libremedia/libremedia-api.js | 35 + js/libremedia/libremedia-artwork.js | 43 + js/libremedia/libremedia-downloader.js | 16 + js/libremedia/libremedia-navigation.js | 204 ++++ js/libremedia/libremedia-pages.js | 253 +++++ js/libremedia/libremedia-playback.js | 338 +++++++ js/libremedia/libremedia-search.js | 36 + js/libremedia/libremedia-tables.js | 196 ++++ js/libremedia/libremedia-transcript.js | 116 +++ js/libremedia/libremedia-utils.js | 63 ++ js/libremedia/libremedia.js | 67 ++ main.go | 412 ++++++++ objalbum.go | 39 + objartwork.go | 26 + objcreator.go | 35 + objdisc.go | 22 + objects.go | 356 +++++++ objformat.go | 32 + objsearch.go | 26 + objstream.go | 100 ++ objtranscript.go | 15 + plugins.go | 127 +++ service.go | 123 +++ spotify.go | 700 +++++++++++++ tidal.go | 1271 ++++++++++++++++++++++++ 42 files changed, 5599 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 builders.go create mode 100644 css/audioplayer.css create mode 100644 css/libremedia.css create mode 100644 favicon.ico create mode 100644 go.mod create mode 100644 go.sum create mode 100644 img/flaticon-freepik-singer.png create mode 100644 img/icons8-download-96.png create mode 100644 img/icons8-play-96.png create mode 100644 img/loading.gif create mode 100644 index.html create mode 100644 js/FileSaver.min.js create mode 100644 js/jszip-utils.min.js create mode 100644 js/jszip.min.js create mode 100644 js/libremedia/audioplayer.js create mode 100644 js/libremedia/libremedia-api.js create mode 100644 js/libremedia/libremedia-artwork.js create mode 100644 js/libremedia/libremedia-downloader.js create mode 100644 js/libremedia/libremedia-navigation.js create mode 100644 js/libremedia/libremedia-pages.js create mode 100644 js/libremedia/libremedia-playback.js create mode 100644 js/libremedia/libremedia-search.js create mode 100644 js/libremedia/libremedia-tables.js create mode 100644 js/libremedia/libremedia-transcript.js create mode 100644 js/libremedia/libremedia-utils.js create mode 100644 js/libremedia/libremedia.js create mode 100644 main.go create mode 100644 objalbum.go create mode 100644 objartwork.go create mode 100644 objcreator.go create mode 100644 objdisc.go create mode 100644 objects.go create mode 100644 objformat.go create mode 100644 objsearch.go create mode 100644 objstream.go create mode 100644 objtranscript.go create mode 100644 plugins.go create mode 100644 service.go create mode 100644 spotify.go create mode 100644 tidal.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..132b555 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Configuration +config*.json + +# Cache +cache/ +*.blob + +# Output executables +*.exe +*.app +*.old +*.bak + +# Most probable output executables for Linux +libremedia.app + +# Log files +*.log + +# Supervise +run +supervise/ + +# OS leftovers +desktop.ini diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9d3bae --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +### libremedia progress tracker before release + +# User interface + +- Fetch artwork for an object using `:artwork` URI extension instead of re-fetching the entire source object, use `:1280` to smartly limit size to 1280px or next size up for max quality +- Add visibility toggle buttons to both the search bar and the audio player +- Increase size of controls in tables to maximize room for touch screens, OR migrate all controls to drop-down list, whichever works out better (or condense controls to it if table entry is squished?) + +## Pages + +- Display top 100 streams and top 100 downloads on home page +- Add "download album" control on album page, generates and saves single-folder ZIP of all streams client-side with per-stream progress bar +- Add "download discography" control on creator page, generates and saves multi-folder ZIP of all albums, EPs and singles client-side with per-album and per-stream progress bars +- Add playlists section on search and creator pages, uses same handler for album objects +- Display entry numbers and total X of each table section + +- Create queue management using /queue with no params +* Generic streams table, only shows either the regular queue or the shuffled queue +* Add reorder control, injects clickable dividers in-between streams that will move selected stream to that location +* Add unqueue control, removes stream from queue +* Allow saving current queue as a libremedia playlist + +- Rewrite logic for transcript page +* Fetch transcript with timings using `:transcript` URI extension instead of re-fetching entire stream object +* Start audio position 0 with empty line (unless first line begins at pos 0) using timings index 0 instead of fake -1 like before (invalid index) +* Signal unplayed/restarted auto-scroller position tracker using -1 instead of -2 like before +* Always load now playing transcript into timings buffer when stream changes, automaticlly scroll back to top +* Add fixed resume auto-scroll button to bottom right when user scrolls to disable auto-scroll + +## Audio player + +- Add vertical volume slider +- Add horizontal audio position seeker directly above audio player, show regardless of audio player visibility +- Change transcript button to specify no params instead of pointing to now playing stream URI, no params will sync transcript with now playing +- Display smaller album art somewhere, use either same size or next size up for max quality + +- Add shuffled queue support +* Place shuffle button somewhere +* Generate new shuffled queue using existing queue on every enable, and use it for next/prev handling until disable +* On disable, return to original queue regardless of what was next prior to enable + +# Backend + +- Start anonymously tracking listen and download counts +- At end of object expansion goroutine, spawn new goroutine to search other providers for matching object to fill in missing metadata (such as credited creators, biographies, artwork, albums, streams, etc) +- Migrate all client-side player controls to the server, simulating client actions based on client requests +- Require clients to request to start playback (automatically acting as "I'm ready" for shared sessions to minimize latency), so they always load from `/v1/stream` with no params afterward +- Add `/v1/providers` endpoint to return all upstream providers and a path to retrieve their icons, which can follow upstream to the source provider +- Add providers array param to `/v1/search` endpoint, allows client-side filtering of providers via request (useful to minimize processing and deduplicate responses when a provider is shared across upstream instances) +- Convert transcript handler to be separated transcript providers, also available as plugins +- Allow catalogue and database providers to be implemented as multimedia providers, without the streams +- Implement support for ffmpeg (for custom format, codec, and quality params, plus metadata injection with `/v1/download` endpoint) +- Implement support for ffprobe (pointing to internal `/v1/stream` API) to identify format details if not available on provider, but direct stream is available +- Find a reliable and free way to identify audio streams with no metadata + +## Plugins + +- Finish writing new plugin system, simple concept but a lot to explain, later +- Implement the Rust librespot client as a binary plugin to provide Spotify, and add podcast support +- Remove hardcoded Spotify provider +- Migrate hardcoded Tidal provider to a binary plugin, and add music video support +- Create plugins for many other things, like local content, disc drive / ISO support, torrent support, YouTube, Bandcamp, SoundCloud, Apple Music, Deezer, etc + +## User accounts + +- Provide a default guest user using server config +- Provide a default admin user with permission to manage additional users and control the quality settings, globally and of each user + +- Cache now playing stream to disk as it buffers (can be random access too) +* Hold lock on file until all sessions holding lock either timeout or all choose new streams +* Avoid repeated reads with session sharing by using the same buffer, as all sessions are synced + +- Add session sharing support +* Generate an invite link to share with any user +* Sync queue, now playing, and audio position in real time (with both a low-latency and data saver mode client-side) +* If sharer toggles it, users may vote to skip with >50% majority +* If sharer toggles it, users may append to queue (with a rotational selection mode in client join order, and a free for all mode) diff --git a/builders.go b/builders.go new file mode 100644 index 0000000..dc7178d --- /dev/null +++ b/builders.go @@ -0,0 +1,149 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" +) + +// GetObject returns an object, either from the cache, or live if possible +func GetObject(uri string) (obj *Object) { + //Try the cache first, it will update frequently during a live expansion + obj = GetObjectCached(uri) + if obj != nil { + return obj + } + + //Fetch the object live + obj = GetObjectLive(uri) + if obj == nil { + return + } + + obj.Sync() + return +} + +// GetObjectLive returns a live object from a given URI +func GetObjectLive(mediaURI string) (obj *Object) { + if mediaURI == "" { + Error.Println("Cannot get object with empty mediaURI") + return nil + } + + obj = &Object{URI: mediaURI, Object: &json.RawMessage{}} + Trace.Println("Fetching " + mediaURI + " live") + + splitURI := strings.Split(mediaURI, ":") + switch splitURI[0] { + case "bestmatch": //Returns an object that best matches the given search query + if len(splitURI) < 2 { + return NewObjError("bestmatch: need query") + } + query := strings.ReplaceAll(splitURI[1], "+", " ") + query = strings.ReplaceAll(query, "%20", " ") + query = strings.ToLower(query) + searchResultsObj := GetObjectLive("search:" + splitURI[1]) + if searchResultsObj.Type != "search" { + return searchResultsObj + } + searchResults := searchResultsObj.SearchResults() + if len(searchResults.Streams) > 0 { + return GetObjectLive(searchResults.Streams[0].Stream().URI) + } + if len(searchResults.Creators) > 0 { + return GetObjectLive(searchResults.Creators[0].Creator().URI) + } + if len(searchResults.Albums) > 0 { + return GetObjectLive(searchResults.Albums[0].Album().URI) + } + return NewObjError("bestmatch: try a better query") + case "search": //Main search handler + if len(splitURI) < 2 { + return NewObjError("search: need query") + } + query := strings.ReplaceAll(splitURI[1], "+", " ") + query = strings.ReplaceAll(query, "%20", " ") + query = strings.ToLower(query) + obj.URI = "search:" + query + results := &ObjectSearchResults{Query: query} + for i := 0; i < len(providers); i++ { + Trace.Println("Searching for '" + query + "' on " + providers[i]) + handler := handlers[providers[i]] + res, err := handler.Search(query) + if err != nil { + Error.Printf("Error searching on %s: %v", providers[i], err) + continue + } + if len(res.Creators) > 0 { + results.Creators = append(results.Creators, res.Creators...) + } + if len(res.Albums) > 0 { + results.Albums = append(results.Albums, res.Albums...) + } + if len(res.Streams) > 0 { + results.Streams = append(results.Streams, res.Streams...) + } + } + obj.Type = "search" + obj.Provider = "libremedia" + resultsJSON, err := json.Marshal(results) + if err != nil { + Error.Printf("Unable to marshal search results: %v\n", err) + return + } + + obj.Object.UnmarshalJSON(resultsJSON) + return + } + + if handler, exists := handlers[splitURI[0]]; exists { + obj.Provider = splitURI[0] + if len(splitURI) > 2 { + id := splitURI[2] + switch splitURI[1] { + case "artist", "creator", "user", "channel", "chan", "streamer": + creator, err := handler.Creator(id) + if err != nil { + return NewObjError(fmt.Sprintf("invalid creator %s: %v", id, err)) + } + obj.Type = "creator" + creatorJSON, err := json.Marshal(creator) + if err != nil { + Error.Printf("Unable to marshal creator: %v\n", err) + return + } + obj.Object.UnmarshalJSON(creatorJSON) + case "album": + album, err := handler.Album(id) + if err != nil { + return NewObjError(fmt.Sprintf("invalid album %s: %v", id, err)) + } + obj.Type = "album" + albumJSON, err := json.Marshal(album) + if err != nil { + Error.Printf("Unable to marshal album: %v\n", err) + return + } + obj.Object.UnmarshalJSON(albumJSON) + case "track", "song", "video", "audio", "stream": + stream, err := handler.Stream(id) + if err != nil { + return NewObjError(fmt.Sprintf("invalid stream %s: %v", id, err)) + } + obj.Type = "stream" + stream.Transcribe() + streamJSON, err := json.Marshal(stream) + if err != nil { + Error.Printf("Unable to marshal stream: %v\n", err) + return + } + obj.Object.UnmarshalJSON(streamJSON) + } + + return obj + } + } + + return nil +} diff --git a/css/audioplayer.css b/css/audioplayer.css new file mode 100644 index 0000000..e8b9f3c --- /dev/null +++ b/css/audioplayer.css @@ -0,0 +1,23 @@ +#audioControls button { + margin: 15px; + font-size: 2em; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2); + cursor: crosshair; + text-align: center; + + padding: 0; + border: none; + background: none; + color: white; + background-color: transparent; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +#audioControls button:focus { + outline: none; +} \ No newline at end of file diff --git a/css/libremedia.css b/css/libremedia.css new file mode 100644 index 0000000..b8203ec --- /dev/null +++ b/css/libremedia.css @@ -0,0 +1,267 @@ +/* inter font family */ + +/* theme colors */ + +:root { + --theme-background: 0, 0, 0; + --theme-background-a: 10, 10, 10, 0.5; + --theme-background-overlay: 20, 20, 20, 0.9; + --theme-text: 250, 250, 250; + --theme-accent: 187, 178, 233; + --theme-stream: 227, 218, 255; + --theme-creator: 177, 168, 223; + --theme-album: 177, 158, 243; + --theme-datetime: 177, 168, 223; +} + +/* general theme styling */ + +html { + background: rgb(var(--theme-background)); + color: rgb(var(--theme-text)); + font-family: "Inter", sans-serif; + padding: 0; + margin: 0; +} + +@supports (font-variation-settings: normal) { + html { + font-family: "Inter var", sans-serif; + } +} + +body { + padding: 0; + margin: 0; + background-attachment: fixed; + background-position: center; /* Center the image */ + background-repeat: no-repeat; /* Do not repeat the image */ + background-size: contain; /* Resize the background image to cover the entire container */ + max-width: 100vw; + width: 100%; +} + +#more, #showLess { + display: none; +} + +#nav { + position: fixed; + margin: 15px; + display: block; + text-align: left; + font-size: 2em; + color: rgb(var(--theme-text)); + bottom: 0; +} + +#results { + padding-bottom: 100vh; + position: relative; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 1fr; + grid-template-areas: "results"; + grid-area: results; + text-align: center; +} + +table { + margin: 10px; + padding-top: 75px; + backdrop-filter: blur(3px) brightness(75%) saturate(150%); + border-collapse: separate; + border-spacing: 5px; + border-radius: 20px; + background-color: rgba(var(--theme-background-a)); + line-height: 1.5em; + letter-spacing: 1.1px; + table-layout: fixed; +} + +th { + font-size: 1.4em; + text-align: center; + height: 60px; +} + +td { + border-bottom: 0.1px dotted rgb(var(--theme-accent)); +} + +#controls { + min-width: 84px; + word-spacing: 10px; +} + +#genre { + color: rgb(var(--theme-accent)); +} + +#lyric { + cursor: pointer; + border: 0; + border-style: none none none none; + font-size: 2.7em; + font-weight: bolder; + padding: 0px !important; + letter-spacing: 1.1px; + height: 200px; + line-height: 1.2em; + width: 100%; +} +#lyricplayed { + color: rgba(var(--theme-accent)); +} +#lyricblank { + cursor: pointer; + border: 0; + border-style: none none none none; + font-size: 2.7em; + font-weight: bolder; + padding: 0px !important; + height: 85vh; + width: 100%; +} + +#infobar { + display: flex; + justify-content: center; + align-items: center; + position: fixed; + bottom: 0; + left: 0; + width: 100%; + flex-direction: column; + text-align: center; + font-size: 1.1em; + padding-top: 5px; + padding-bottom: 5px; + border-top: 1px dotted rgb(var(--theme-text)); + border-bottom: 1px dashed rgb(var(--theme-text)); + background-color: rgba(var(--theme-background-overlay)); +} + +#infobar #hidden { + display: none; +} + +#audioInfo { + color: rgb(var(--theme-accent)); +} + +#search { + display: block; + padding: 0px; + border-top: 0.5px solid rgb(var(--theme-text)); + border-bottom: 0.5px solid rgb(var(--theme-text)); + background-color: rgba(var(--theme-background-overlay)); + top: 0; + position: fixed; + width: 100%; + height: 80px; +} + +#search #hidden { + display: none; +} + +#searching { + text-align: center; + margin-top: 30px; + font-size: 5em; + background-color: rgba(var(--theme-background-overlay)); +} + +#notification { + text-align: center; + margin-top: 10px; + font-size: 2em; + background-color: rgba(var(--theme-background-overlay)); +} + +#stream a { + color: rgb(var(--theme-stream)); +} + +#creator a { + color: rgb(var(--theme-creator)); +} + +#album a { + color: rgb(var(--theme-album)); +} + +#datetime { + font-size: 0.8em; + color: rgb(var(--theme-datetime)); +} + +#genre { + font-size: 2em; + text-align: right; +} + +/* element styling */ + +h1 { + font-weight: bold; +} + +h2 {} + +h3 {} + +a { + color: rgb(var(--theme-accent)); + text-decoration: none; +} + +a:hover { + border-bottom: 0.5px solid rgb(var(--theme-accent)); +} + +a:focus { + outline: none; +} + +img { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +input[type="text"] { + background: none; + border: none; + border-bottom: 0.5px solid rgb(var(--theme-text)); + color: rgb(var(--theme-text)); + font-size: 2em; + text-align: center; + height: 70px; + width: 90%; + margin-left: 5%; + padding-bottom: 2px; +} + +input[type="text"]:focus, input[type="text"]:hover { + border-bottom: 0.5px solid rgb(var(--theme-accent)); +} + +input[type="text"]:focus { + outline: none; +} + +#downloadProgress { + width: 100%; +} + +#progressBar { + width: 0%; + height: 30px; + background-color: #04AA6D; + text-align: center; /* To center it horizontally (if you want) */ + line-height: 30px; /* To center it vertically */ + color: white; + display: none; +} diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..58455807f6818287ed0b81302c672cc9cbb90449 GIT binary patch literal 67646 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 ../../librespot-org/librespot-golang diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..944a8df --- /dev/null +++ b/go.sum @@ -0,0 +1,189 @@ +github.com/JoshuaDoes/json v0.0.0-20200726213358-ec3860544ac0 h1:315Zb0n+8KwZyUiIKbDGvfrQ003c0XfHYNy0M4vv5cA= +github.com/JoshuaDoes/json v0.0.0-20200726213358-ec3860544ac0/go.mod h1:vsCdx75bni6k6GIQPPima8KiM7ZjNHeBJ8keBaJOADA= +github.com/PuerkitoBio/goquery v1.4.1 h1:smcIRGdYm/w7JSbcdeLHEMzxmsBQvl8lhf0dSw2nzMI= +github.com/PuerkitoBio/goquery v1.4.1/go.mod h1:T9ezsOHcCrDCgA8aF1Cqr3sSYbO/xgdy8/R/XiIMAhA= +github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= +github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= +github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o= +github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= +github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= +github.com/arcspace/go-arc-sdk v0.0.0-20230515171510-a7903dbeb29e h1:73JE2PD8YgVytKO784w0j6MKOhX+I3hathkO97Ke95I= +github.com/arcspace/go-arc-sdk v0.0.0-20230515171510-a7903dbeb29e/go.mod h1:H9VafuwVN/Ara71F34vS7BLOtgYuEvRp6d9F9yxBHw4= +github.com/arcspace/go-arc-sdk v0.0.0-20230617152009-007f190f76b2 h1:R+BQfwHZd6/0ae0lIiCamHLIegpOFgwkBgB+bi1yAu8= +github.com/arcspace/go-arc-sdk v0.0.0-20230617152009-007f190f76b2/go.mod h1:jNQ8o4nnXyd/UyleKA3YnCHZCBNZE2XoAC4t7+8i+/E= +github.com/arcspace/go-librespot v0.0.0-20230613175017-c712547862d3 h1:4kY6RnTSFQPWI5GdMbDu9ZzpnktDuYpZDw8e4ytHpM0= +github.com/arcspace/go-librespot v0.0.0-20230613175017-c712547862d3/go.mod h1:nP+DqnQ49fJut1FM5x+uIQIGWGYUTcyHvHSmdOQf184= +github.com/badfortrains/mdns v0.0.0-20160325001438-447166384f51 h1:b6+GdpGhuzxPETrVLwY77iq0L/BqLpcPRmaLNW+SoRY= +github.com/badfortrains/mdns v0.0.0-20160325001438-447166384f51/go.mod h1:qHRkxMBkkpAWD2poeBYQt6O5IS4dE6w1Cr4Z8Q3feXI= +github.com/brynbellomy/klog v0.0.0-20200414031930-87fbf2e555ae h1:FO8VxsnMvWNRzx3vGjBmS2kotWl9f455Yj0H+9k01zk= +github.com/brynbellomy/klog v0.0.0-20200414031930-87fbf2e555ae/go.mod h1:ZecQZYfGLYeVNx5ooyrBwTVsXx+7mi7bpuQLgTxClfQ= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E= +github.com/dsoprea/go-logging v0.0.0-20190624164917-c4f10aab7696 h1:VGFnZAcLwPpt1sHlAxml+pGLZz9A2s+K/s1YNhPC91Y= +github.com/dsoprea/go-logging v0.0.0-20190624164917-c4f10aab7696/go.mod h1:Nm/x2ZUNRW6Fe5C3LxdY1PyZY5wmDv/s5dkPJ/VB3iA= +github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd h1:l+vLbuxptsC6VQyQsfD7NnEC8BZuFpz45PgY+pH8YTg= +github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8= +github.com/dsoprea/go-utility v0.0.0-20221003172846-a3e1774ef349 h1:/py11NlxDaOxkT9OKN+gXgT+QOH5xj1ZRoyusfRIlo4= +github.com/dsoprea/go-utility v0.0.0-20221003172846-a3e1774ef349/go.mod h1:KVK+/Hul09ujXAGq+42UBgCTnXkiJZRnLYdURGjQUwo= +github.com/eolso/librespot-golang v0.0.0-20230506023304-cdb078f4ea7f h1:JFuBM9Utu0TuuqlxaKLaKMP672ElZGcHek6GHPofAeU= +github.com/eolso/librespot-golang v0.0.0-20230506023304-cdb078f4ea7f/go.mod h1:ZMdmntH4Ph3WzSmazGIs2XLN8OVfJeCbKT/ZFSn8Pa0= +github.com/eolso/threadsafe v0.0.0-20230304165831-d28da4e4d0d3 h1:GHaXZmcRxj3dUR9KLvI2wp73giddMtua29jXCFtfdY8= +github.com/eolso/threadsafe v0.0.0-20230304165831-d28da4e4d0d3/go.mod h1:RTB7Uo8r+9gpIcLXvsuRAv+pgabBfpuBqAooOvOGhSQ= +github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= +github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gosimple/slug v1.2.0 h1:DqQXHQLprYBsiO4ZtdadqBeKh7CFnl5qoVNkKkVI7No= +github.com/gosimple/slug v1.2.0/go.mod h1:ER78kgg1Mv0NQGlXiDe57DpCyfbNywXXZ9mIorhxAf0= +github.com/gosimple/slug v1.13.1 h1:bQ+kpX9Qa6tHRaK+fZR0A0M2Kd7Pa5eHPPsb1JpHD+Q= +github.com/gosimple/slug v1.13.1/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= +github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= +github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= +github.com/jfbus/httprs v1.0.1 h1:kIf3dk5QlEiBDPnY88BRHI6iQ1HepvYD8Fb8zVmiNDU= +github.com/jfbus/httprs v1.0.1/go.mod h1:M9fpbEbf1Ns5RSaTkvnykqBCdJkwNtYAoAC73Ie9bEs= +github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/librespot-org/librespot-golang v0.0.0-20220325184705-31669e5a889f h1:tTMPsyVClxxV+CnlLqzgxTTTFM6J7i//rSejN1Md0b0= +github.com/librespot-org/librespot-golang v0.0.0-20220325184705-31669e5a889f/go.mod h1:LeHPXRci2Bg6RBmw8BDEFH5nfb8oGU6mll+qTH8UIzw= +github.com/miekg/dns v1.1.8/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/miekg/dns v1.1.54 h1:5jon9mWcb0sFJGpnI99tOMhCPyJ+RPVz5b63MQG0VWI= +github.com/miekg/dns v1.1.54/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= +github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= +github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= +github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= +github.com/rhnvrm/lyric-api-go v0.1.4 h1:E3n6+H4PyVJCesc3NNIbLxKlFpyMVDiq2niKSZK7K1s= +github.com/rhnvrm/lyric-api-go v0.1.4/go.mod h1:mxXSk7lWgck4BEMLsOgse2AiE+qoXIc9mcfxF2YQCHs= +github.com/rs/cors v1.9.0 h1:l9HGsTsHJcvW14Nk7J9KFz8bzeAWXn3CG6bgt7LsrAE= +github.com/rs/cors v1.9.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v0.0.0-20180222194500-ef6db91d284a/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= +github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/xlab/portaudio-go v0.0.0-20170905165025-132d041879db h1:sSIQlvfIWUHLDhEWUL2K2CeYv9CDksC00VxuxPUe4lw= +github.com/xlab/portaudio-go v0.0.0-20170905165025-132d041879db/go.mod h1:r57mRacDQMS6Fz8ubv1nE8zZ0DbQ/sY0NkCmdxeUXmY= +github.com/xlab/vorbis-go v0.0.0-20190125051917-087364aef51d/go.mod h1:AMqfx3jFwPqem3u8mF2lsRodZs30jG/Mag5HZ3mB3sA= +github.com/xlab/vorbis-go v0.0.0-20210911202351-b5b85f1ec645 h1:lYg/+vV/Fd5WM1+Ptg54Am3y4mDXaMSrT+mKUHV5uVc= +github.com/xlab/vorbis-go v0.0.0-20210911202351-b5b85f1ec645/go.mod h1:AMqfx3jFwPqem3u8mF2lsRodZs30jG/Mag5HZ3mB3sA= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190418165655-df01cb2cc480/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ= +golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM= +golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= +golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc= +golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= +golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= +golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190420063019-afa5a82059c6/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU= +golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= +golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g= +golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= +golang.org/x/oauth2 v0.9.0 h1:BPpt2kU7oMRq3kCHAA1tbSEshXRw1LpG2ztgDwrzuAs= +golang.org/x/oauth2 v0.9.0/go.mod h1:qYgFZaFiu6Wg24azG8bdV52QJXJGbZzIIsRCdVKzbLw= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y= +golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= +golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg= +golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/img/flaticon-freepik-singer.png b/img/flaticon-freepik-singer.png new file mode 100644 index 0000000000000000000000000000000000000000..f91270441c5bb4781145e8126419dee75cf7995d GIT binary patch literal 22234 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 literal 0 HcmV?d00001 diff --git a/img/icons8-download-96.png b/img/icons8-download-96.png new file mode 100644 index 0000000000000000000000000000000000000000..2e7effad3ae4e13d032f97342a8d65ab833f0b46 GIT binary patch literal 1338 zcmV-A1;zS_P))zno=x+cB_a~p_-kzaIIDU z68``db=!qM7HZRkrV-j*5yXv^DxwI|ih?F-LPML>kBhmL+P*h4cjnH!?`6&h-fCvf z`QGz=^JebMxryXBj^j9v<2a7vIF94g0oVyl<<`+Vfc?M{uoXUE0f%$z>IJ|Pz#4b5 z1{}(*Z`%XR4=DakPUY4Yi{%y&3$QNfo*|pp&1`y`&8n3c;6-4i!9Z?hF(O|n5pmA6)VvHFZC15J0B=w(O480Y0s@xv03pK`lJazs- zg36j>Zx+9^{>)Jua7*6l8YawjmH$>DKURo1)^YwU287%%SERof8#DSg6u2U(>>EC9 zX6vzWBl2f!&INW$S}oaS&CGVk#B5?f*6!r(mVd0Ch5}s|Zwg2?cS*un7yujwPD{E< z@mOLGIFK;@W5B$mtCBW=bHG!n;*XTn7r{{cK@*~7;7Hs2z!3)D=WjGIA@%s9Ni6_4 zT@r4E+f0bbPf`4`C$(pDk3X7}0)QP9-^4QhXF}clR2LtL8GY0`>o*0 zqVJWo$bg@tVT64b!?9&-)yUr|<8TqK^D?Hk2@%DYq^0mB$VT>g0Pv`!ZzSDUWA{~6 z7q-qmlC&b}X*2t&#=ifNdi>eX05khp(sPpjuCaTa!q(YmRniNw#qWab21t^beJkmC z)ia?jtCEhI+1De;RmoTwAdSp~u`0eaMg~Zon=p38m&VKhsWTJCviOZiJrOC%^Ca+B zRlXYBF`I6wYb4_z3jqmCNLu`$sXqpUd`(SAUi_e^Uj~Gnu_km<{Gg?O286tIP3Wxn zK}SCg2)V0F=(PAjLw^kj`G>t=8TbOHgZ%wAAZXYRdaJgdpGYw)+3eQ$-ztp(`s@u~q zY{#7&@=dcX&<#g`hc6Q)>_`Ge~9^7MgaNe9gA&)5SS%>&Hr z4@s}6XzlAeHzd8*r1(;ciI|x!NqRd7=qp>2j+@y+(*Ug>oKgJAf{)d|pQB|-ubSDJ zHbGiHiZHW9N&6(dBk5Y(coV>;qz@&{v@O1r_+OY%tQ4P>bWGACk{*(je6jY3S(o&i wq+cY>Njll&ZpU#P$8j9TaU92S9LI6~1!6O1AO0XuM*si-07*qoM6N<$f(x91;{X5v literal 0 HcmV?d00001 diff --git a/img/icons8-play-96.png b/img/icons8-play-96.png new file mode 100644 index 0000000000000000000000000000000000000000..94605c2ebc2302418655240fc2464c9bf2bc116d GIT binary patch literal 1091 zcmV-J1ibr+P)fYefhaFOmpqZU%uMi;sebYTlyx@=id zV2D;wlqG@gOa(!A{@Jy22+d4Uq7svos8EY)DicO%O8vFC@1?h#nfvEGXU_feeAef^ z_uTX2+-L5*?>z^cbIv*EoO69+ArB_$L0|*08dwU<2Yv?50!Pg3hmhxU1CquhZIyIW zGng#B&!7XJ} z7bUGreJ!mcX}P4^0RqS!m-IyH>uL>2I|B}W?vA9xk{(HYeT^sSiwFVaZb^De*`Kv; zI+AWg3?TQbyiiYX4hDbNB}p4nUvmt#pM)hTWJgLm1H5czXA+z{ig~F!P|?%CX=R0a zN$Spo(6$*c>@Q#s@S2(3Ox%~++8jXcH()2Q%gm-ycc!W?1dzJ|Yy*y(S=0J9qb>!I zI}5yGW~Wnkq>Q!|;WAbNrv`<3Y3j}t>Ea9+mKW;R&FqiVotaM81IYajyhj5IbzcI= zT?MuShniBT`xZd%99f}0mAWH+4IuX+@RFH*ld1z<{41!6^}r=bA4po5q62-O0mE(p z?*hBc?9aJ6sRwYcYh+K^HN!qa4Ts7|my7h5AI@19$*ojFUZ4U#$8;FT!Eu zg?fjiu__<*84%t{V7-}5mTBxWAiPz;!Lp4#fH*cvdag`EzXio}!OR{nYUBaL^{APB zJzE361;w?jsDTF%SK*5$9zbFqKwRG!HShr9D*V@k2N2JfW_G!#kp~dZ+hrPg0C5~M zv%_T?dH~^l1Z*zb*aHaXHt-Ix&dhF={gA(Z8%FIfWITY;jQo|P--7D74m=M$Im+OJ z&w#3e|18A=DCckB0I=Q6ehGPC51^R;G%=C$z$!Ca8*A{v1DMV=vInWBV*fH4{1!AU z$qO1Dz`q4{o0(0_^~w7lK=POL`WirEUQ_K`5xz=RU{^L_@S#65ps`C8bUlE^FV@l3 z00bNbUNy6CQ+H-IT@1h%Y)a$`unBmkErSo;m;tR`w6DzpbYj`cHU^Lv*ez!EUFyz^ zs%-&uZt3FMLaxkRLT4B6u1(oyJhA!m^)r!lA>zRM1`b6e?TRq)cO)HUK literal 0 HcmV?d00001 diff --git a/img/loading.gif b/img/loading.gif new file mode 100644 index 0000000000000000000000000000000000000000..5f752e5b8702c3f685e96faf83220ee2d2e120f7 GIT binary patch literal 15100 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 literal 0 HcmV?d00001 diff --git a/index.html b/index.html new file mode 100644 index 0000000..694fbb0 --- /dev/null +++ b/index.html @@ -0,0 +1,48 @@ + + + + libremedia + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+
+
+
0%
+
+
+ + + + diff --git a/js/FileSaver.min.js b/js/FileSaver.min.js new file mode 100644 index 0000000..6d493b2 --- /dev/null +++ b/js/FileSaver.min.js @@ -0,0 +1,3 @@ +(function(a,b){if("function"==typeof define&&define.amd)define([],b);else if("undefined"!=typeof exports)b();else{b(),a.FileSaver={exports:{}}.exports}})(this,function(){"use strict";function b(a,b){return"undefined"==typeof b?b={autoBom:!1}:"object"!=typeof b&&(console.warn("Deprecated: Expected third argument to be a object"),b={autoBom:!b}),b.autoBom&&/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(a.type)?new Blob(["\uFEFF",a],{type:a.type}):a}function c(a,b,c){var d=new XMLHttpRequest;d.open("GET",a),d.responseType="blob",d.onload=function(){g(d.response,b,c)},d.onerror=function(){console.error("could not download file")},d.send()}function d(a){var b=new XMLHttpRequest;b.open("HEAD",a,!1);try{b.send()}catch(a){}return 200<=b.status&&299>=b.status}function e(a){try{a.dispatchEvent(new MouseEvent("click"))}catch(c){var b=document.createEvent("MouseEvents");b.initMouseEvent("click",!0,!0,window,0,0,0,80,20,!1,!1,!1,!1,0,null),a.dispatchEvent(b)}}var f="object"==typeof window&&window.window===window?window:"object"==typeof self&&self.self===self?self:"object"==typeof global&&global.global===global?global:void 0,a=/Macintosh/.test(navigator.userAgent)&&/AppleWebKit/.test(navigator.userAgent)&&!/Safari/.test(navigator.userAgent),g=f.saveAs||("object"!=typeof window||window!==f?function(){}:"download"in HTMLAnchorElement.prototype&&!a?function(b,g,h){var i=f.URL||f.webkitURL,j=document.createElement("a");g=g||b.name||"download",j.download=g,j.rel="noopener","string"==typeof b?(j.href=b,j.origin===location.origin?e(j):d(j.href)?c(b,g,h):e(j,j.target="_blank")):(j.href=i.createObjectURL(b),setTimeout(function(){i.revokeObjectURL(j.href)},4E4),setTimeout(function(){e(j)},0))}:"msSaveOrOpenBlob"in navigator?function(f,g,h){if(g=g||f.name||"download","string"!=typeof f)navigator.msSaveOrOpenBlob(b(f,h),g);else if(d(f))c(f,g,h);else{var i=document.createElement("a");i.href=f,i.target="_blank",setTimeout(function(){e(i)})}}:function(b,d,e,g){if(g=g||open("","_blank"),g&&(g.document.title=g.document.body.innerText="downloading..."),"string"==typeof b)return c(b,d,e);var h="application/octet-stream"===b.type,i=/constructor/i.test(f.HTMLElement)||f.safari,j=/CriOS\/[\d]+/.test(navigator.userAgent);if((j||h&&i||a)&&"undefined"!=typeof FileReader){var k=new FileReader;k.onloadend=function(){var a=k.result;a=j?a:a.replace(/^data:[^;]*;/,"data:attachment/file;"),g?g.location.href=a:location=a,g=null},k.readAsDataURL(b)}else{var l=f.URL||f.webkitURL,m=l.createObjectURL(b);g?g.location=m:location.href=m,g=null,setTimeout(function(){l.revokeObjectURL(m)},4E4)}});f.saveAs=g.saveAs=g,"undefined"!=typeof module&&(module.exports=g)}); + +//# sourceMappingURL=FileSaver.min.js.map \ No newline at end of file diff --git a/js/jszip-utils.min.js b/js/jszip-utils.min.js new file mode 100644 index 0000000..aa91052 --- /dev/null +++ b/js/jszip-utils.min.js @@ -0,0 +1 @@ +!function(e){"object"==typeof exports?module.exports=e():"function"==typeof define&&define.amd?define(e):"undefined"!=typeof window?window.JSZipUtils=e():"undefined"!=typeof global?global.JSZipUtils=e():"undefined"!=typeof self&&(self.JSZipUtils=e())}(function(){return function o(i,f,u){function s(n,e){if(!f[n]){if(!i[n]){var t="function"==typeof require&&require;if(!e&&t)return t(n,!0);if(a)return a(n,!0);throw new Error("Cannot find module '"+n+"'")}var r=f[n]={exports:{}};i[n][0].call(r.exports,function(e){var t=i[n][1][e];return s(t||e)},r,r.exports,o,i,f,u)}return f[n].exports}for(var a="function"==typeof require&&require,e=0;e + +(c) 2009-2016 Stuart Knightley +Dual licenced under the MIT license or GPLv3. See https://raw.github.com/Stuk/jszip/master/LICENSE.markdown. + +JSZip uses the library pako released under the MIT license : +https://github.com/nodeca/pako/blob/master/LICENSE +*/ + +!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).JSZip=e()}}(function(){return function s(a,o,u){function h(r,e){if(!o[r]){if(!a[r]){var t="function"==typeof require&&require;if(!e&&t)return t(r,!0);if(f)return f(r,!0);var n=new Error("Cannot find module '"+r+"'");throw n.code="MODULE_NOT_FOUND",n}var i=o[r]={exports:{}};a[r][0].call(i.exports,function(e){var t=a[r][1][e];return h(t||e)},i,i.exports,s,a,o,u)}return o[r].exports}for(var f="function"==typeof require&&require,e=0;e>2,s=(3&t)<<4|r>>4,a=1>6:64,o=2>4,r=(15&i)<<4|(s=p.indexOf(e.charAt(o++)))>>2,n=(3&s)<<6|(a=p.indexOf(e.charAt(o++))),h[u++]=t,64!==s&&(h[u++]=r),64!==a&&(h[u++]=n);return h}},{"./support":30,"./utils":32}],2:[function(e,t,r){"use strict";var n=e("./external"),i=e("./stream/DataWorker"),s=e("./stream/Crc32Probe"),a=e("./stream/DataLengthProbe");function o(e,t,r,n,i){this.compressedSize=e,this.uncompressedSize=t,this.crc32=r,this.compression=n,this.compressedContent=i}o.prototype={getContentWorker:function(){var e=new i(n.Promise.resolve(this.compressedContent)).pipe(this.compression.uncompressWorker()).pipe(new a("data_length")),t=this;return e.on("end",function(){if(this.streamInfo.data_length!==t.uncompressedSize)throw new Error("Bug : uncompressed data size mismatch")}),e},getCompressedWorker:function(){return new i(n.Promise.resolve(this.compressedContent)).withStreamInfo("compressedSize",this.compressedSize).withStreamInfo("uncompressedSize",this.uncompressedSize).withStreamInfo("crc32",this.crc32).withStreamInfo("compression",this.compression)}},o.createWorkerFrom=function(e,t,r){return e.pipe(new s).pipe(new a("uncompressedSize")).pipe(t.compressWorker(r)).pipe(new a("compressedSize")).withStreamInfo("compression",t)},t.exports=o},{"./external":6,"./stream/Crc32Probe":25,"./stream/DataLengthProbe":26,"./stream/DataWorker":27}],3:[function(e,t,r){"use strict";var n=e("./stream/GenericWorker");r.STORE={magic:"\0\0",compressWorker:function(e){return new n("STORE compression")},uncompressWorker:function(){return new n("STORE decompression")}},r.DEFLATE=e("./flate")},{"./flate":7,"./stream/GenericWorker":28}],4:[function(e,t,r){"use strict";var n=e("./utils"),a=function(){for(var e,t=[],r=0;r<256;r++){e=r;for(var n=0;n<8;n++)e=1&e?3988292384^e>>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t){return void 0!==e&&e.length?"string"!==n.getTypeOf(e)?function(e,t,r){var n=a,i=0+r;e^=-1;for(var s=0;s>>8^n[255&(e^t[s])];return-1^e}(0|t,e,e.length):function(e,t,r){var n=a,i=0+r;e^=-1;for(var s=0;s>>8^n[255&(e^t.charCodeAt(s))];return-1^e}(0|t,e,e.length):0}},{"./utils":32}],5:[function(e,t,r){"use strict";r.base64=!1,r.binary=!1,r.dir=!1,r.createFolders=!0,r.date=null,r.compression=null,r.compressionOptions=null,r.comment=null,r.unixPermissions=null,r.dosPermissions=null},{}],6:[function(e,t,r){"use strict";var n;n="undefined"!=typeof Promise?Promise:e("lie"),t.exports={Promise:n}},{lie:37}],7:[function(e,t,r){"use strict";var n="undefined"!=typeof Uint8Array&&"undefined"!=typeof Uint16Array&&"undefined"!=typeof Uint32Array,i=e("pako"),s=e("./utils"),a=e("./stream/GenericWorker"),o=n?"uint8array":"array";function u(e,t){a.call(this,"FlateWorker/"+e),this._pako=null,this._pakoAction=e,this._pakoOptions=t,this.meta={}}r.magic="\b\0",s.inherits(u,a),u.prototype.processChunk=function(e){this.meta=e.meta,null===this._pako&&this._createPako(),this._pako.push(s.transformTo(o,e.data),!1)},u.prototype.flush=function(){a.prototype.flush.call(this),null===this._pako&&this._createPako(),this._pako.push([],!0)},u.prototype.cleanUp=function(){a.prototype.cleanUp.call(this),this._pako=null},u.prototype._createPako=function(){this._pako=new i[this._pakoAction]({raw:!0,level:this._pakoOptions.level||-1});var t=this;this._pako.onData=function(e){t.push({data:e,meta:t.meta})}},r.compressWorker=function(e){return new u("Deflate",e)},r.uncompressWorker=function(){return new u("Inflate",{})}},{"./stream/GenericWorker":28,"./utils":32,pako:38}],8:[function(e,t,r){"use strict";function I(e,t){var r,n="";for(r=0;r>>=8;return n}function i(e,t,r,n,i,s){var a,o,u=e.file,h=e.compression,f=s!==B.utf8encode,l=O.transformTo("string",s(u.name)),d=O.transformTo("string",B.utf8encode(u.name)),c=u.comment,p=O.transformTo("string",s(c)),m=O.transformTo("string",B.utf8encode(c)),_=d.length!==u.name.length,g=m.length!==c.length,v="",b="",w="",y=u.dir,k=u.date,x={crc32:0,compressedSize:0,uncompressedSize:0};t&&!r||(x.crc32=e.crc32,x.compressedSize=e.compressedSize,x.uncompressedSize=e.uncompressedSize);var S=0;t&&(S|=8),f||!_&&!g||(S|=2048);var z,E=0,C=0;y&&(E|=16),"UNIX"===i?(C=798,E|=((z=u.unixPermissions)||(z=y?16893:33204),(65535&z)<<16)):(C=20,E|=63&(u.dosPermissions||0)),a=k.getUTCHours(),a<<=6,a|=k.getUTCMinutes(),a<<=5,a|=k.getUTCSeconds()/2,o=k.getUTCFullYear()-1980,o<<=4,o|=k.getUTCMonth()+1,o<<=5,o|=k.getUTCDate(),_&&(v+="up"+I((b=I(1,1)+I(T(l),4)+d).length,2)+b),g&&(v+="uc"+I((w=I(1,1)+I(T(p),4)+m).length,2)+w);var A="";return A+="\n\0",A+=I(S,2),A+=h.magic,A+=I(a,2),A+=I(o,2),A+=I(x.crc32,4),A+=I(x.compressedSize,4),A+=I(x.uncompressedSize,4),A+=I(l.length,2),A+=I(v.length,2),{fileRecord:R.LOCAL_FILE_HEADER+A+l+v,dirRecord:R.CENTRAL_FILE_HEADER+I(C,2)+A+I(p.length,2)+"\0\0\0\0"+I(E,4)+I(n,4)+l+v+p}}var O=e("../utils"),s=e("../stream/GenericWorker"),B=e("../utf8"),T=e("../crc32"),R=e("../signature");function n(e,t,r,n){s.call(this,"ZipFileWorker"),this.bytesWritten=0,this.zipComment=t,this.zipPlatform=r,this.encodeFileName=n,this.streamFiles=e,this.accumulate=!1,this.contentBuffer=[],this.dirRecords=[],this.currentSourceOffset=0,this.entriesCount=0,this.currentFile=null,this._sources=[]}O.inherits(n,s),n.prototype.push=function(e){var t=e.meta.percent||0,r=this.entriesCount,n=this._sources.length;this.accumulate?this.contentBuffer.push(e):(this.bytesWritten+=e.data.length,s.prototype.push.call(this,{data:e.data,meta:{currentFile:this.currentFile,percent:r?(t+100*(r-n-1))/r:100}}))},n.prototype.openedSource=function(e){this.currentSourceOffset=this.bytesWritten,this.currentFile=e.file.name;var t=this.streamFiles&&!e.file.dir;if(t){var r=i(e,t,!1,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);this.push({data:r.fileRecord,meta:{percent:0}})}else this.accumulate=!0},n.prototype.closedSource=function(e){this.accumulate=!1;var t,r=this.streamFiles&&!e.file.dir,n=i(e,r,!0,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);if(this.dirRecords.push(n.dirRecord),r)this.push({data:(t=e,R.DATA_DESCRIPTOR+I(t.crc32,4)+I(t.compressedSize,4)+I(t.uncompressedSize,4)),meta:{percent:100}});else for(this.push({data:n.fileRecord,meta:{percent:0}});this.contentBuffer.length;)this.push(this.contentBuffer.shift());this.currentFile=null},n.prototype.flush=function(){for(var e=this.bytesWritten,t=0;t=this.index;t--)r=(r<<8)+this.byteAt(t);return this.index+=e,r},readString:function(e){return n.transformTo("string",this.readData(e))},readData:function(e){},lastIndexOfSignature:function(e){},readAndCheckSignature:function(e){},readDate:function(){var e=this.readInt(4);return new Date(Date.UTC(1980+(e>>25&127),(e>>21&15)-1,e>>16&31,e>>11&31,e>>5&63,(31&e)<<1))}},t.exports=i},{"../utils":32}],19:[function(e,t,r){"use strict";var n=e("./Uint8ArrayReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.readData=function(e){this.checkOffset(e);var t=this.data.slice(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./Uint8ArrayReader":21}],20:[function(e,t,r){"use strict";var n=e("./DataReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.byteAt=function(e){return this.data.charCodeAt(this.zero+e)},i.prototype.lastIndexOfSignature=function(e){return this.data.lastIndexOf(e)-this.zero},i.prototype.readAndCheckSignature=function(e){return e===this.readData(4)},i.prototype.readData=function(e){this.checkOffset(e);var t=this.data.slice(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./DataReader":18}],21:[function(e,t,r){"use strict";var n=e("./ArrayReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.readData=function(e){if(this.checkOffset(e),0===e)return new Uint8Array(0);var t=this.data.subarray(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./ArrayReader":17}],22:[function(e,t,r){"use strict";var n=e("../utils"),i=e("../support"),s=e("./ArrayReader"),a=e("./StringReader"),o=e("./NodeBufferReader"),u=e("./Uint8ArrayReader");t.exports=function(e){var t=n.getTypeOf(e);return n.checkSupport(t),"string"!==t||i.uint8array?"nodebuffer"===t?new o(e):i.uint8array?new u(n.transformTo("uint8array",e)):new s(n.transformTo("array",e)):new a(e)}},{"../support":30,"../utils":32,"./ArrayReader":17,"./NodeBufferReader":19,"./StringReader":20,"./Uint8ArrayReader":21}],23:[function(e,t,r){"use strict";r.LOCAL_FILE_HEADER="PK",r.CENTRAL_FILE_HEADER="PK",r.CENTRAL_DIRECTORY_END="PK",r.ZIP64_CENTRAL_DIRECTORY_LOCATOR="PK",r.ZIP64_CENTRAL_DIRECTORY_END="PK",r.DATA_DESCRIPTOR="PK\b"},{}],24:[function(e,t,r){"use strict";var n=e("./GenericWorker"),i=e("../utils");function s(e){n.call(this,"ConvertWorker to "+e),this.destType=e}i.inherits(s,n),s.prototype.processChunk=function(e){this.push({data:i.transformTo(this.destType,e.data),meta:e.meta})},t.exports=s},{"../utils":32,"./GenericWorker":28}],25:[function(e,t,r){"use strict";var n=e("./GenericWorker"),i=e("../crc32");function s(){n.call(this,"Crc32Probe"),this.withStreamInfo("crc32",0)}e("../utils").inherits(s,n),s.prototype.processChunk=function(e){this.streamInfo.crc32=i(e.data,this.streamInfo.crc32||0),this.push(e)},t.exports=s},{"../crc32":4,"../utils":32,"./GenericWorker":28}],26:[function(e,t,r){"use strict";var n=e("../utils"),i=e("./GenericWorker");function s(e){i.call(this,"DataLengthProbe for "+e),this.propName=e,this.withStreamInfo(e,0)}n.inherits(s,i),s.prototype.processChunk=function(e){if(e){var t=this.streamInfo[this.propName]||0;this.streamInfo[this.propName]=t+e.data.length}i.prototype.processChunk.call(this,e)},t.exports=s},{"../utils":32,"./GenericWorker":28}],27:[function(e,t,r){"use strict";var n=e("../utils"),i=e("./GenericWorker");function s(e){i.call(this,"DataWorker");var t=this;this.dataIsReady=!1,this.index=0,this.max=0,this.data=null,this.type="",this._tickScheduled=!1,e.then(function(e){t.dataIsReady=!0,t.data=e,t.max=e&&e.length||0,t.type=n.getTypeOf(e),t.isPaused||t._tickAndRepeat()},function(e){t.error(e)})}n.inherits(s,i),s.prototype.cleanUp=function(){i.prototype.cleanUp.call(this),this.data=null},s.prototype.resume=function(){return!!i.prototype.resume.call(this)&&(!this._tickScheduled&&this.dataIsReady&&(this._tickScheduled=!0,n.delay(this._tickAndRepeat,[],this)),!0)},s.prototype._tickAndRepeat=function(){this._tickScheduled=!1,this.isPaused||this.isFinished||(this._tick(),this.isFinished||(n.delay(this._tickAndRepeat,[],this),this._tickScheduled=!0))},s.prototype._tick=function(){if(this.isPaused||this.isFinished)return!1;var e=null,t=Math.min(this.max,this.index+16384);if(this.index>=this.max)return this.end();switch(this.type){case"string":e=this.data.substring(this.index,t);break;case"uint8array":e=this.data.subarray(this.index,t);break;case"array":case"nodebuffer":e=this.data.slice(this.index,t)}return this.index=t,this.push({data:e,meta:{percent:this.max?this.index/this.max*100:0}})},t.exports=s},{"../utils":32,"./GenericWorker":28}],28:[function(e,t,r){"use strict";function n(e){this.name=e||"default",this.streamInfo={},this.generatedError=null,this.extraStreamInfo={},this.isPaused=!0,this.isFinished=!1,this.isLocked=!1,this._listeners={data:[],end:[],error:[]},this.previous=null}n.prototype={push:function(e){this.emit("data",e)},end:function(){if(this.isFinished)return!1;this.flush();try{this.emit("end"),this.cleanUp(),this.isFinished=!0}catch(e){this.emit("error",e)}return!0},error:function(e){return!this.isFinished&&(this.isPaused?this.generatedError=e:(this.isFinished=!0,this.emit("error",e),this.previous&&this.previous.error(e),this.cleanUp()),!0)},on:function(e,t){return this._listeners[e].push(t),this},cleanUp:function(){this.streamInfo=this.generatedError=this.extraStreamInfo=null,this._listeners=[]},emit:function(e,t){if(this._listeners[e])for(var r=0;r "+e:e}},t.exports=n},{}],29:[function(e,t,r){"use strict";var h=e("../utils"),i=e("./ConvertWorker"),s=e("./GenericWorker"),f=e("../base64"),n=e("../support"),a=e("../external"),o=null;if(n.nodestream)try{o=e("../nodejs/NodejsStreamOutputAdapter")}catch(e){}function u(e,t,r){var n=t;switch(t){case"blob":case"arraybuffer":n="uint8array";break;case"base64":n="string"}try{this._internalType=n,this._outputType=t,this._mimeType=r,h.checkSupport(n),this._worker=e.pipe(new i(n)),e.lock()}catch(e){this._worker=new s("error"),this._worker.error(e)}}u.prototype={accumulate:function(e){return o=this,u=e,new a.Promise(function(t,r){var n=[],i=o._internalType,s=o._outputType,a=o._mimeType;o.on("data",function(e,t){n.push(e),u&&u(t)}).on("error",function(e){n=[],r(e)}).on("end",function(){try{var e=function(e,t,r){switch(e){case"blob":return h.newBlob(h.transformTo("arraybuffer",t),r);case"base64":return f.encode(t);default:return h.transformTo(e,t)}}(s,function(e,t){var r,n=0,i=null,s=0;for(r=0;r>>6:(r<65536?t[s++]=224|r>>>12:(t[s++]=240|r>>>18,t[s++]=128|r>>>12&63),t[s++]=128|r>>>6&63),t[s++]=128|63&r);return t}(e)},s.utf8decode=function(e){return u.nodebuffer?o.transformTo("nodebuffer",e).toString("utf-8"):function(e){var t,r,n,i,s=e.length,a=new Array(2*s);for(t=r=0;t>10&1023,a[r++]=56320|1023&n)}return a.length!==r&&(a.subarray?a=a.subarray(0,r):a.length=r),o.applyFromCharCode(a)}(e=o.transformTo(u.uint8array?"uint8array":"array",e))},o.inherits(a,n),a.prototype.processChunk=function(e){var t=o.transformTo(u.uint8array?"uint8array":"array",e.data);if(this.leftOver&&this.leftOver.length){if(u.uint8array){var r=t;(t=new Uint8Array(r.length+this.leftOver.length)).set(this.leftOver,0),t.set(r,this.leftOver.length)}else t=this.leftOver.concat(t);this.leftOver=null}var n=function(e,t){var r;for((t=t||e.length)>e.length&&(t=e.length),r=t-1;0<=r&&128==(192&e[r]);)r--;return r<0?t:0===r?t:r+h[e[r]]>t?r:t}(t),i=t;n!==t.length&&(u.uint8array?(i=t.subarray(0,n),this.leftOver=t.subarray(n,t.length)):(i=t.slice(0,n),this.leftOver=t.slice(n,t.length))),this.push({data:s.utf8decode(i),meta:e.meta})},a.prototype.flush=function(){this.leftOver&&this.leftOver.length&&(this.push({data:s.utf8decode(this.leftOver),meta:{}}),this.leftOver=null)},s.Utf8DecodeWorker=a,o.inherits(f,n),f.prototype.processChunk=function(e){this.push({data:s.utf8encode(e.data),meta:e.meta})},s.Utf8EncodeWorker=f},{"./nodejsUtils":14,"./stream/GenericWorker":28,"./support":30,"./utils":32}],32:[function(e,t,o){"use strict";var u=e("./support"),h=e("./base64"),r=e("./nodejsUtils"),n=e("set-immediate-shim"),f=e("./external");function i(e){return e}function l(e,t){for(var r=0;r>8;this.dir=!!(16&this.externalFileAttributes),0==e&&(this.dosPermissions=63&this.externalFileAttributes),3==e&&(this.unixPermissions=this.externalFileAttributes>>16&65535),this.dir||"/"!==this.fileNameStr.slice(-1)||(this.dir=!0)},parseZIP64ExtraField:function(e){if(this.extraFields[1]){var t=n(this.extraFields[1].value);this.uncompressedSize===s.MAX_VALUE_32BITS&&(this.uncompressedSize=t.readInt(8)),this.compressedSize===s.MAX_VALUE_32BITS&&(this.compressedSize=t.readInt(8)),this.localHeaderOffset===s.MAX_VALUE_32BITS&&(this.localHeaderOffset=t.readInt(8)),this.diskNumberStart===s.MAX_VALUE_32BITS&&(this.diskNumberStart=t.readInt(4))}},readExtraFields:function(e){var t,r,n,i=e.index+this.extraFieldsLength;for(this.extraFields||(this.extraFields={});e.index+4>>6:(r<65536?t[s++]=224|r>>>12:(t[s++]=240|r>>>18,t[s++]=128|r>>>12&63),t[s++]=128|r>>>6&63),t[s++]=128|63&r);return t},r.buf2binstring=function(e){return f(e,e.length)},r.binstring2buf=function(e){for(var t=new u.Buf8(e.length),r=0,n=t.length;r>10&1023,o[n++]=56320|1023&i)}return f(o,n)},r.utf8border=function(e,t){var r;for((t=t||e.length)>e.length&&(t=e.length),r=t-1;0<=r&&128==(192&e[r]);)r--;return r<0?t:0===r?t:r+h[e[r]]>t?r:t}},{"./common":41}],43:[function(e,t,r){"use strict";t.exports=function(e,t,r,n){for(var i=65535&e|0,s=e>>>16&65535|0,a=0;0!==r;){for(r-=a=2e3>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t[a])];return-1^e}},{}],46:[function(e,t,r){"use strict";var u,d=e("../utils/common"),h=e("./trees"),c=e("./adler32"),p=e("./crc32"),n=e("./messages"),f=0,l=0,m=-2,i=2,_=8,s=286,a=30,o=19,g=2*s+1,v=15,b=3,w=258,y=w+b+1,k=42,x=113;function S(e,t){return e.msg=n[t],t}function z(e){return(e<<1)-(4e.avail_out&&(r=e.avail_out),0!==r&&(d.arraySet(e.output,t.pending_buf,t.pending_out,r,e.next_out),e.next_out+=r,t.pending_out+=r,e.total_out+=r,e.avail_out-=r,t.pending-=r,0===t.pending&&(t.pending_out=0))}function A(e,t){h._tr_flush_block(e,0<=e.block_start?e.block_start:-1,e.strstart-e.block_start,t),e.block_start=e.strstart,C(e.strm)}function I(e,t){e.pending_buf[e.pending++]=t}function O(e,t){e.pending_buf[e.pending++]=t>>>8&255,e.pending_buf[e.pending++]=255&t}function B(e,t){var r,n,i=e.max_chain_length,s=e.strstart,a=e.prev_length,o=e.nice_match,u=e.strstart>e.w_size-y?e.strstart-(e.w_size-y):0,h=e.window,f=e.w_mask,l=e.prev,d=e.strstart+w,c=h[s+a-1],p=h[s+a];e.prev_length>=e.good_match&&(i>>=2),o>e.lookahead&&(o=e.lookahead);do{if(h[(r=t)+a]===p&&h[r+a-1]===c&&h[r]===h[s]&&h[++r]===h[s+1]){s+=2,r++;do{}while(h[++s]===h[++r]&&h[++s]===h[++r]&&h[++s]===h[++r]&&h[++s]===h[++r]&&h[++s]===h[++r]&&h[++s]===h[++r]&&h[++s]===h[++r]&&h[++s]===h[++r]&&su&&0!=--i);return a<=e.lookahead?a:e.lookahead}function T(e){var t,r,n,i,s,a,o,u,h,f,l=e.w_size;do{if(i=e.window_size-e.lookahead-e.strstart,e.strstart>=l+(l-y)){for(d.arraySet(e.window,e.window,l,l,0),e.match_start-=l,e.strstart-=l,e.block_start-=l,t=r=e.hash_size;n=e.head[--t],e.head[t]=l<=n?n-l:0,--r;);for(t=r=l;n=e.prev[--t],e.prev[t]=l<=n?n-l:0,--r;);i+=l}if(0===e.strm.avail_in)break;if(a=e.strm,o=e.window,u=e.strstart+e.lookahead,f=void 0,(h=i)<(f=a.avail_in)&&(f=h),r=0===f?0:(a.avail_in-=f,d.arraySet(o,a.input,a.next_in,f,u),1===a.state.wrap?a.adler=c(a.adler,o,f,u):2===a.state.wrap&&(a.adler=p(a.adler,o,f,u)),a.next_in+=f,a.total_in+=f,f),e.lookahead+=r,e.lookahead+e.insert>=b)for(s=e.strstart-e.insert,e.ins_h=e.window[s],e.ins_h=(e.ins_h<=b&&(e.ins_h=(e.ins_h<=b)if(n=h._tr_tally(e,e.strstart-e.match_start,e.match_length-b),e.lookahead-=e.match_length,e.match_length<=e.max_lazy_match&&e.lookahead>=b){for(e.match_length--;e.strstart++,e.ins_h=(e.ins_h<=b&&(e.ins_h=(e.ins_h<=b&&e.match_length<=e.prev_length){for(i=e.strstart+e.lookahead-b,n=h._tr_tally(e,e.strstart-1-e.prev_match,e.prev_length-b),e.lookahead-=e.prev_length-1,e.prev_length-=2;++e.strstart<=i&&(e.ins_h=(e.ins_h<e.pending_buf_size-5&&(r=e.pending_buf_size-5);;){if(e.lookahead<=1){if(T(e),0===e.lookahead&&t===f)return 1;if(0===e.lookahead)break}e.strstart+=e.lookahead,e.lookahead=0;var n=e.block_start+r;if((0===e.strstart||e.strstart>=n)&&(e.lookahead=e.strstart-n,e.strstart=n,A(e,!1),0===e.strm.avail_out))return 1;if(e.strstart-e.block_start>=e.w_size-y&&(A(e,!1),0===e.strm.avail_out))return 1}return e.insert=0,4===t?(A(e,!0),0===e.strm.avail_out?3:4):(e.strstart>e.block_start&&(A(e,!1),e.strm.avail_out),1)}),new F(4,4,8,4,R),new F(4,5,16,8,R),new F(4,6,32,32,R),new F(4,4,16,16,D),new F(8,16,32,32,D),new F(8,16,128,128,D),new F(8,32,128,256,D),new F(32,128,258,1024,D),new F(32,258,258,4096,D)],r.deflateInit=function(e,t){return L(e,t,_,15,8,0)},r.deflateInit2=L,r.deflateReset=P,r.deflateResetKeep=U,r.deflateSetHeader=function(e,t){return e&&e.state?2!==e.state.wrap?m:(e.state.gzhead=t,l):m},r.deflate=function(e,t){var r,n,i,s;if(!e||!e.state||5>8&255),I(n,n.gzhead.time>>16&255),I(n,n.gzhead.time>>24&255),I(n,9===n.level?2:2<=n.strategy||n.level<2?4:0),I(n,255&n.gzhead.os),n.gzhead.extra&&n.gzhead.extra.length&&(I(n,255&n.gzhead.extra.length),I(n,n.gzhead.extra.length>>8&255)),n.gzhead.hcrc&&(e.adler=p(e.adler,n.pending_buf,n.pending,0)),n.gzindex=0,n.status=69):(I(n,0),I(n,0),I(n,0),I(n,0),I(n,0),I(n,9===n.level?2:2<=n.strategy||n.level<2?4:0),I(n,3),n.status=x);else{var a=_+(n.w_bits-8<<4)<<8;a|=(2<=n.strategy||n.level<2?0:n.level<6?1:6===n.level?2:3)<<6,0!==n.strstart&&(a|=32),a+=31-a%31,n.status=x,O(n,a),0!==n.strstart&&(O(n,e.adler>>>16),O(n,65535&e.adler)),e.adler=1}if(69===n.status)if(n.gzhead.extra){for(i=n.pending;n.gzindex<(65535&n.gzhead.extra.length)&&(n.pending!==n.pending_buf_size||(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),C(e),i=n.pending,n.pending!==n.pending_buf_size));)I(n,255&n.gzhead.extra[n.gzindex]),n.gzindex++;n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),n.gzindex===n.gzhead.extra.length&&(n.gzindex=0,n.status=73)}else n.status=73;if(73===n.status)if(n.gzhead.name){i=n.pending;do{if(n.pending===n.pending_buf_size&&(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),C(e),i=n.pending,n.pending===n.pending_buf_size)){s=1;break}s=n.gzindexi&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),0===s&&(n.gzindex=0,n.status=91)}else n.status=91;if(91===n.status)if(n.gzhead.comment){i=n.pending;do{if(n.pending===n.pending_buf_size&&(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),C(e),i=n.pending,n.pending===n.pending_buf_size)){s=1;break}s=n.gzindexi&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),0===s&&(n.status=103)}else n.status=103;if(103===n.status&&(n.gzhead.hcrc?(n.pending+2>n.pending_buf_size&&C(e),n.pending+2<=n.pending_buf_size&&(I(n,255&e.adler),I(n,e.adler>>8&255),e.adler=0,n.status=x)):n.status=x),0!==n.pending){if(C(e),0===e.avail_out)return n.last_flush=-1,l}else if(0===e.avail_in&&z(t)<=z(r)&&4!==t)return S(e,-5);if(666===n.status&&0!==e.avail_in)return S(e,-5);if(0!==e.avail_in||0!==n.lookahead||t!==f&&666!==n.status){var o=2===n.strategy?function(e,t){for(var r;;){if(0===e.lookahead&&(T(e),0===e.lookahead)){if(t===f)return 1;break}if(e.match_length=0,r=h._tr_tally(e,0,e.window[e.strstart]),e.lookahead--,e.strstart++,r&&(A(e,!1),0===e.strm.avail_out))return 1}return e.insert=0,4===t?(A(e,!0),0===e.strm.avail_out?3:4):e.last_lit&&(A(e,!1),0===e.strm.avail_out)?1:2}(n,t):3===n.strategy?function(e,t){for(var r,n,i,s,a=e.window;;){if(e.lookahead<=w){if(T(e),e.lookahead<=w&&t===f)return 1;if(0===e.lookahead)break}if(e.match_length=0,e.lookahead>=b&&0e.lookahead&&(e.match_length=e.lookahead)}if(e.match_length>=b?(r=h._tr_tally(e,1,e.match_length-b),e.lookahead-=e.match_length,e.strstart+=e.match_length,e.match_length=0):(r=h._tr_tally(e,0,e.window[e.strstart]),e.lookahead--,e.strstart++),r&&(A(e,!1),0===e.strm.avail_out))return 1}return e.insert=0,4===t?(A(e,!0),0===e.strm.avail_out?3:4):e.last_lit&&(A(e,!1),0===e.strm.avail_out)?1:2}(n,t):u[n.level].func(n,t);if(3!==o&&4!==o||(n.status=666),1===o||3===o)return 0===e.avail_out&&(n.last_flush=-1),l;if(2===o&&(1===t?h._tr_align(n):5!==t&&(h._tr_stored_block(n,0,0,!1),3===t&&(E(n.head),0===n.lookahead&&(n.strstart=0,n.block_start=0,n.insert=0))),C(e),0===e.avail_out))return n.last_flush=-1,l}return 4!==t?l:n.wrap<=0?1:(2===n.wrap?(I(n,255&e.adler),I(n,e.adler>>8&255),I(n,e.adler>>16&255),I(n,e.adler>>24&255),I(n,255&e.total_in),I(n,e.total_in>>8&255),I(n,e.total_in>>16&255),I(n,e.total_in>>24&255)):(O(n,e.adler>>>16),O(n,65535&e.adler)),C(e),0=r.w_size&&(0===s&&(E(r.head),r.strstart=0,r.block_start=0,r.insert=0),h=new d.Buf8(r.w_size),d.arraySet(h,t,f-r.w_size,r.w_size,0),t=h,f=r.w_size),a=e.avail_in,o=e.next_in,u=e.input,e.avail_in=f,e.next_in=0,e.input=t,T(r);r.lookahead>=b;){for(n=r.strstart,i=r.lookahead-(b-1);r.ins_h=(r.ins_h<>>=w=b>>>24,p-=w,0==(w=b>>>16&255))E[s++]=65535&b;else{if(!(16&w)){if(0==(64&w)){b=m[(65535&b)+(c&(1<>>=w,p-=w),p<15&&(c+=z[n++]<>>=w=b>>>24,p-=w,!(16&(w=b>>>16&255))){if(0==(64&w)){b=_[(65535&b)+(c&(1<>>=w,p-=w,(w=s-a)>3,c&=(1<<(p-=y<<3))-1,e.next_in=n,e.next_out=s,e.avail_in=n>>24&255)+(e>>>8&65280)+((65280&e)<<8)+((255&e)<<24)}function s(){this.mode=0,this.last=!1,this.wrap=0,this.havedict=!1,this.flags=0,this.dmax=0,this.check=0,this.total=0,this.head=null,this.wbits=0,this.wsize=0,this.whave=0,this.wnext=0,this.window=null,this.hold=0,this.bits=0,this.length=0,this.offset=0,this.extra=0,this.lencode=null,this.distcode=null,this.lenbits=0,this.distbits=0,this.ncode=0,this.nlen=0,this.ndist=0,this.have=0,this.next=null,this.lens=new I.Buf16(320),this.work=new I.Buf16(288),this.lendyn=null,this.distdyn=null,this.sane=0,this.back=0,this.was=0}function a(e){var t;return e&&e.state?(t=e.state,e.total_in=e.total_out=t.total=0,e.msg="",t.wrap&&(e.adler=1&t.wrap),t.mode=P,t.last=0,t.havedict=0,t.dmax=32768,t.head=null,t.hold=0,t.bits=0,t.lencode=t.lendyn=new I.Buf32(n),t.distcode=t.distdyn=new I.Buf32(i),t.sane=1,t.back=-1,N):U}function o(e){var t;return e&&e.state?((t=e.state).wsize=0,t.whave=0,t.wnext=0,a(e)):U}function u(e,t){var r,n;return e&&e.state?(n=e.state,t<0?(r=0,t=-t):(r=1+(t>>4),t<48&&(t&=15)),t&&(t<8||15=s.wsize?(I.arraySet(s.window,t,r-s.wsize,s.wsize,0),s.wnext=0,s.whave=s.wsize):(n<(i=s.wsize-s.wnext)&&(i=n),I.arraySet(s.window,t,r-n,i,s.wnext),(n-=i)?(I.arraySet(s.window,t,r-n,n,0),s.wnext=n,s.whave=s.wsize):(s.wnext+=i,s.wnext===s.wsize&&(s.wnext=0),s.whave>>8&255,r.check=B(r.check,C,2,0),f=h=0,r.mode=2;break}if(r.flags=0,r.head&&(r.head.done=!1),!(1&r.wrap)||(((255&h)<<8)+(h>>8))%31){e.msg="incorrect header check",r.mode=30;break}if(8!=(15&h)){e.msg="unknown compression method",r.mode=30;break}if(f-=4,k=8+(15&(h>>>=4)),0===r.wbits)r.wbits=k;else if(k>r.wbits){e.msg="invalid window size",r.mode=30;break}r.dmax=1<>8&1),512&r.flags&&(C[0]=255&h,C[1]=h>>>8&255,r.check=B(r.check,C,2,0)),f=h=0,r.mode=3;case 3:for(;f<32;){if(0===o)break e;o--,h+=n[s++]<>>8&255,C[2]=h>>>16&255,C[3]=h>>>24&255,r.check=B(r.check,C,4,0)),f=h=0,r.mode=4;case 4:for(;f<16;){if(0===o)break e;o--,h+=n[s++]<>8),512&r.flags&&(C[0]=255&h,C[1]=h>>>8&255,r.check=B(r.check,C,2,0)),f=h=0,r.mode=5;case 5:if(1024&r.flags){for(;f<16;){if(0===o)break e;o--,h+=n[s++]<>>8&255,r.check=B(r.check,C,2,0)),f=h=0}else r.head&&(r.head.extra=null);r.mode=6;case 6:if(1024&r.flags&&(o<(c=r.length)&&(c=o),c&&(r.head&&(k=r.head.extra_len-r.length,r.head.extra||(r.head.extra=new Array(r.head.extra_len)),I.arraySet(r.head.extra,n,s,c,k)),512&r.flags&&(r.check=B(r.check,n,c,s)),o-=c,s+=c,r.length-=c),r.length))break e;r.length=0,r.mode=7;case 7:if(2048&r.flags){if(0===o)break e;for(c=0;k=n[s+c++],r.head&&k&&r.length<65536&&(r.head.name+=String.fromCharCode(k)),k&&c>9&1,r.head.done=!0),e.adler=r.check=0,r.mode=12;break;case 10:for(;f<32;){if(0===o)break e;o--,h+=n[s++]<>>=7&f,f-=7&f,r.mode=27;break}for(;f<3;){if(0===o)break e;o--,h+=n[s++]<>>=1)){case 0:r.mode=14;break;case 1:if(j(r),r.mode=20,6!==t)break;h>>>=2,f-=2;break e;case 2:r.mode=17;break;case 3:e.msg="invalid block type",r.mode=30}h>>>=2,f-=2;break;case 14:for(h>>>=7&f,f-=7&f;f<32;){if(0===o)break e;o--,h+=n[s++]<>>16^65535)){e.msg="invalid stored block lengths",r.mode=30;break}if(r.length=65535&h,f=h=0,r.mode=15,6===t)break e;case 15:r.mode=16;case 16:if(c=r.length){if(o>>=5,f-=5,r.ndist=1+(31&h),h>>>=5,f-=5,r.ncode=4+(15&h),h>>>=4,f-=4,286>>=3,f-=3}for(;r.have<19;)r.lens[A[r.have++]]=0;if(r.lencode=r.lendyn,r.lenbits=7,S={bits:r.lenbits},x=R(0,r.lens,0,19,r.lencode,0,r.work,S),r.lenbits=S.bits,x){e.msg="invalid code lengths set",r.mode=30;break}r.have=0,r.mode=19;case 19:for(;r.have>>16&255,v=65535&E,!((_=E>>>24)<=f);){if(0===o)break e;o--,h+=n[s++]<>>=_,f-=_,r.lens[r.have++]=v;else{if(16===v){for(z=_+2;f>>=_,f-=_,0===r.have){e.msg="invalid bit length repeat",r.mode=30;break}k=r.lens[r.have-1],c=3+(3&h),h>>>=2,f-=2}else if(17===v){for(z=_+3;f>>=_)),h>>>=3,f-=3}else{for(z=_+7;f>>=_)),h>>>=7,f-=7}if(r.have+c>r.nlen+r.ndist){e.msg="invalid bit length repeat",r.mode=30;break}for(;c--;)r.lens[r.have++]=k}}if(30===r.mode)break;if(0===r.lens[256]){e.msg="invalid code -- missing end-of-block",r.mode=30;break}if(r.lenbits=9,S={bits:r.lenbits},x=R(D,r.lens,0,r.nlen,r.lencode,0,r.work,S),r.lenbits=S.bits,x){e.msg="invalid literal/lengths set",r.mode=30;break}if(r.distbits=6,r.distcode=r.distdyn,S={bits:r.distbits},x=R(F,r.lens,r.nlen,r.ndist,r.distcode,0,r.work,S),r.distbits=S.bits,x){e.msg="invalid distances set",r.mode=30;break}if(r.mode=20,6===t)break e;case 20:r.mode=21;case 21:if(6<=o&&258<=u){e.next_out=a,e.avail_out=u,e.next_in=s,e.avail_in=o,r.hold=h,r.bits=f,T(e,d),a=e.next_out,i=e.output,u=e.avail_out,s=e.next_in,n=e.input,o=e.avail_in,h=r.hold,f=r.bits,12===r.mode&&(r.back=-1);break}for(r.back=0;g=(E=r.lencode[h&(1<>>16&255,v=65535&E,!((_=E>>>24)<=f);){if(0===o)break e;o--,h+=n[s++]<>b)])>>>16&255,v=65535&E,!(b+(_=E>>>24)<=f);){if(0===o)break e;o--,h+=n[s++]<>>=b,f-=b,r.back+=b}if(h>>>=_,f-=_,r.back+=_,r.length=v,0===g){r.mode=26;break}if(32&g){r.back=-1,r.mode=12;break}if(64&g){e.msg="invalid literal/length code",r.mode=30;break}r.extra=15&g,r.mode=22;case 22:if(r.extra){for(z=r.extra;f>>=r.extra,f-=r.extra,r.back+=r.extra}r.was=r.length,r.mode=23;case 23:for(;g=(E=r.distcode[h&(1<>>16&255,v=65535&E,!((_=E>>>24)<=f);){if(0===o)break e;o--,h+=n[s++]<>b)])>>>16&255,v=65535&E,!(b+(_=E>>>24)<=f);){if(0===o)break e;o--,h+=n[s++]<>>=b,f-=b,r.back+=b}if(h>>>=_,f-=_,r.back+=_,64&g){e.msg="invalid distance code",r.mode=30;break}r.offset=v,r.extra=15&g,r.mode=24;case 24:if(r.extra){for(z=r.extra;f>>=r.extra,f-=r.extra,r.back+=r.extra}if(r.offset>r.dmax){e.msg="invalid distance too far back",r.mode=30;break}r.mode=25;case 25:if(0===u)break e;if(c=d-u,r.offset>c){if((c=r.offset-c)>r.whave&&r.sane){e.msg="invalid distance too far back",r.mode=30;break}p=c>r.wnext?(c-=r.wnext,r.wsize-c):r.wnext-c,c>r.length&&(c=r.length),m=r.window}else m=i,p=a-r.offset,c=r.length;for(uc?(m=T[R+a[b]],A[I+a[b]]):(m=96,0),u=1<>S)+(h-=u)]=p<<24|m<<16|_|0,0!==h;);for(u=1<>=1;if(0!==u?(C&=u-1,C+=u):C=0,b++,0==--O[v]){if(v===y)break;v=t[r+a[b]]}if(k>>7)]}function x(e,t){e.pending_buf[e.pending++]=255&t,e.pending_buf[e.pending++]=t>>>8&255}function S(e,t,r){e.bi_valid>i-r?(e.bi_buf|=t<>i-e.bi_valid,e.bi_valid+=r-i):(e.bi_buf|=t<>>=1,r<<=1,0<--t;);return r>>>1}function C(e,t,r){var n,i,s=new Array(_+1),a=0;for(n=1;n<=_;n++)s[n]=a=a+r[n-1]<<1;for(i=0;i<=t;i++){var o=e[2*i+1];0!==o&&(e[2*i]=E(s[o]++,o))}}function A(e){var t;for(t=0;t<286;t++)e.dyn_ltree[2*t]=0;for(t=0;t<30;t++)e.dyn_dtree[2*t]=0;for(t=0;t<19;t++)e.bl_tree[2*t]=0;e.dyn_ltree[512]=1,e.opt_len=e.static_len=0,e.last_lit=e.matches=0}function I(e){8>1;1<=r;r--)B(e,s,r);for(i=u;r=e.heap[1],e.heap[1]=e.heap[e.heap_len--],B(e,s,1),n=e.heap[1],e.heap[--e.heap_max]=r,e.heap[--e.heap_max]=n,s[2*i]=s[2*r]+s[2*n],e.depth[i]=(e.depth[r]>=e.depth[n]?e.depth[r]:e.depth[n])+1,s[2*r+1]=s[2*n+1]=i,e.heap[1]=i++,B(e,s,1),2<=e.heap_len;);e.heap[--e.heap_max]=e.heap[1],function(e,t){var r,n,i,s,a,o,u=t.dyn_tree,h=t.max_code,f=t.stat_desc.static_tree,l=t.stat_desc.has_stree,d=t.stat_desc.extra_bits,c=t.stat_desc.extra_base,p=t.stat_desc.max_length,m=0;for(s=0;s<=_;s++)e.bl_count[s]=0;for(u[2*e.heap[e.heap_max]+1]=0,r=e.heap_max+1;r<573;r++)p<(s=u[2*u[2*(n=e.heap[r])+1]+1]+1)&&(s=p,m++),u[2*n+1]=s,h>=7;n<30;n++)for(w[n]=i<<7,e=0;e<1<>>=1)if(1&r&&0!==e.dyn_ltree[2*t])return 0;if(0!==e.dyn_ltree[18]||0!==e.dyn_ltree[20]||0!==e.dyn_ltree[26])return 1;for(t=32;t<256;t++)if(0!==e.dyn_ltree[2*t])return 1;return 0}(e)),R(e,e.l_desc),R(e,e.d_desc),a=function(e){var t;for(D(e,e.dyn_ltree,e.l_desc.max_code),D(e,e.dyn_dtree,e.d_desc.max_code),R(e,e.bl_desc),t=18;3<=t&&0===e.bl_tree[2*f[t]+1];t--);return e.opt_len+=3*(t+1)+5+5+4,t}(e),i=e.opt_len+3+7>>>3,(s=e.static_len+3+7>>>3)<=i&&(i=s)):i=s=r+5,r+4<=i&&-1!==t?U(e,t,r,n):4===e.strategy||s===i?(S(e,2+(n?1:0),3),T(e,l,d)):(S(e,4+(n?1:0),3),function(e,t,r,n){var i;for(S(e,t-257,5),S(e,r-1,5),S(e,n-4,4),i=0;i>>8&255,e.pending_buf[e.d_buf+2*e.last_lit+1]=255&t,e.pending_buf[e.l_buf+e.last_lit]=255&r,e.last_lit++,0===t?e.dyn_ltree[2*r]++:(e.matches++,t--,e.dyn_ltree[2*(p[r]+256+1)]++,e.dyn_dtree[2*k(t)]++),e.last_lit===e.lit_bufsize-1},r._tr_align=function(e){var t;S(e,2,3),z(e,256,l),16===(t=e).bi_valid?(x(t,t.bi_buf),t.bi_buf=0,t.bi_valid=0):8<=t.bi_valid&&(t.pending_buf[t.pending++]=255&t.bi_buf,t.bi_buf>>=8,t.bi_valid-=8)}},{"../utils/common":41}],53:[function(e,t,r){"use strict";t.exports=function(){this.input=null,this.next_in=0,this.avail_in=0,this.total_in=0,this.output=null,this.next_out=0,this.avail_out=0,this.total_out=0,this.msg="",this.state=null,this.data_type=2,this.adler=0}},{}],54:[function(e,t,r){"use strict";t.exports="function"==typeof setImmediate?setImmediate:function(){var e=[].slice.apply(arguments);e.splice(1,0,0),setTimeout.apply(null,e)}},{}]},{},[10])(10)})}).call(this,void 0!==r?r:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}]},{},[1])(1)})}).call(this,void 0!==r?r:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}]},{},[1])(1)})}).call(this,void 0!==r?r:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}]},{},[1])(1)})}).call(this,void 0!==r?r:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}]},{},[1])(1)})}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}]},{},[1])(1)}); \ No newline at end of file diff --git a/js/libremedia/audioplayer.js b/js/libremedia/audioplayer.js new file mode 100644 index 0000000..0041dc3 --- /dev/null +++ b/js/libremedia/audioplayer.js @@ -0,0 +1,108 @@ +//Elements +var btnPP; +var audio; +var timer; + +//Icons +var play = ''; +var pause = ''; +var loading = ""; + +//State +var ready = false; +var playing = false; +var init = false; + +function audioInit() { + btnPP = document.getElementById("btnPP"); + btnPP.addEventListener("click", audioPP); + audio = document.getElementById("audioPlayer"); + timer = document.getElementById("audioTimer"); + + if (init) { + return; + } + + audio.addEventListener("loadstart", audioLoad); + audio.addEventListener("canplay", audioReady); + audio.addEventListener("pause", audioPause); + audio.addEventListener("play", audioPlay); + audio.addEventListener("playing", audioResume); + audio.addEventListener("timeupdate", audioTime); + audio.addEventListener("waiting", audioBuffer); + audio.addEventListener("ended", audioEnd); + + init = true; +}; + +function audioPP() { + if (audio.paused) { + audioPlay(null); + } else { + audioPause(null); + } +} + +function audioLoad(event) { + ready = false; + playing = false; + if (btnPP == null) { + return; + } + btnPP.innerHTML = loading; +} + +function audioReady(event) { + ready = true; +} + +function audioPlay(event) { + if (!playing) { + audio.play(); + } + playing = true; +} + +function audioPause(event) { + audio.pause(); + if (btnPP == null) { + return; + } + btnPP.innerHTML = play; + playing = false; +} + +function audioResume(event) { + if (btnPP == null) { + return; + } + btnPP.innerHTML = pause; +} + +function audioTime(event) { + if (timer.innerHTML == "") { + return; + } + const pos = audio.currentTime; + const len = audio.duration; + if (len == NaN || len <= 0) { + timer.innerHTML = "0:00 / 0:00"; + return; + } + timer.innerHTML = Math.floor(pos / 60) + ":" + Math.floor(pos % 60).toString().padStart(2, '0') + " / " + Math.floor(len / 60) + ":" + Math.floor(len % 60).toString().padStart(2, '0'); +} + +function audioBuffer(event) { + if (btnPP == null) { + return; + } + btnPP.innerHTML = loading; +} + +function audioEnd(event) { + playing = false; + if (btnPP == null) { + return; + } + btnPP.innerHTML = play; +} \ No newline at end of file diff --git a/js/libremedia/libremedia-api.js b/js/libremedia/libremedia-api.js new file mode 100644 index 0000000..81b3801 --- /dev/null +++ b/js/libremedia/libremedia-api.js @@ -0,0 +1,35 @@ +/* API handlers */ + +function Get(link) { + var HttpReq = new XMLHttpRequest(); + HttpReq.open("GET", link, false); + HttpReq.send(null); + return HttpReq; +} +function GetData(link) { + return Get(link).response; +} +function GetText(link) { + return Get(link).responseText; +} +function GetJson(link) { + var jsonData = GetData(link); + var jsonObj = JSON.parse(jsonData); + return jsonObj; +} + +function v1GetBestMatch(query) { + return GetJson('/v1/bestmatch:' + query); +} +function v1GetSearch(query) { + return GetJson('/v1/search:' + query); +} +function v1GetObject(object) { + return GetJson('/v1/' + object); +} +function v1GetStream(object) { + return GetJson('/v1/stream/' + object); +} +function v1GetStreamBestMatch(query) { + return GetJson('/v1/stream/bestmatch:' + query); +} \ No newline at end of file diff --git a/js/libremedia/libremedia-artwork.js b/js/libremedia/libremedia-artwork.js new file mode 100644 index 0000000..56bdd15 --- /dev/null +++ b/js/libremedia/libremedia-artwork.js @@ -0,0 +1,43 @@ +function setBgImg(url) { + //console.log("setBgImg: " + url); + var newbg = 'url("' + url + '")'; + if (url == "") { + newbg = ""; + } + + if (document.body.style.backgroundImage !== newbg) { + //console.log("Setting background " + url + " using " + newbg + " to replace " + document.body.style.backgroundImage); + document.body.style.backgroundImage = newbg; + bgImg = url; + } +} + +function setBgStream(stream) { + if (stream.album.object.artworks != null && stream.album.object.artworks.length > 0) { + var selbg = stream.album.object.artworks.length-1; + if (selbg > 4) { + selbg = 4; + } + const bestbg = stream.album.object.artworks[selbg]; + setBgImg(bestbg.url); + } +} + +function resetBgImg() { + //console.log("resetBgImg: " + bgImg); + var newbg = ''; + if (nowPlaying != null) { + if (nowPlaying.album.object.artworks != null) { + var selbg = nowPlaying.album.object.artworks.length-1; + if (selbg > 4) { + selbg = 4; + } + const bestbg = nowPlaying.album.object.artworks[selbg]; + newbg = bestbg.url; + } + } + if (newbg == "") { + newbg = bgImg; + } + setBgImg(newbg); +} \ No newline at end of file diff --git a/js/libremedia/libremedia-downloader.js b/js/libremedia/libremedia-downloader.js new file mode 100644 index 0000000..510465f --- /dev/null +++ b/js/libremedia/libremedia-downloader.js @@ -0,0 +1,16 @@ +function downloadStream(match) { + if (match.params == null) { + pagePotato(match); + return; + } + var uri = match.params.uri; + //console.log("Download: " + uri); + window.open("/v1/download/" + uri, "_blank"); + pagePotato(match); +} +function downloadAlbum(albumURI) { + //console.log("Not implemented yet! TODO: Download " + albumURI + " as ZIP"); +} +function downloadDiscography(creatorURI) { + //console.log("Not implemented yet! TODO: Download " + creatorURI + " as ZIP"); +} diff --git a/js/libremedia/libremedia-navigation.js b/js/libremedia/libremedia-navigation.js new file mode 100644 index 0000000..84af7dc --- /dev/null +++ b/js/libremedia/libremedia-navigation.js @@ -0,0 +1,204 @@ +//Navigation map +var navMap = { + "search": displaySearch, + "creator": displayCreator, + "album": displayAlbum, + "transcript": displayTranscript, + "addqueue": queueAddStream, + "stream": playStream, + "download": downloadStream, +} + +function navigoResolve() { + navigo = new Navigo("/", { hash: true }); + navigo + .on("/search", (match) => { + pageObject = []; + navMap["search"](match); + }) + .on("/creator", (match) => { + pageObject = []; + navMap["creator"](match); + }) + .on("/album", (match) => { + pageObject = []; + navMap["album"](match); + }) + .on("/transcript", (match) => { + navMap["transcript"](match); + }) + .on("/addqueue", (match) => { + navMap["addqueue"](match); + }) + .on("/stream", (match) => { + navMap["stream"](match); + }) + .on("/download", (match) => { + navMap["download"](match); + }) + .on("/back", (match) => { + pageRelease(); + }) + .notFound((match) => { + render(match, '
404
' + match.url + '
' + match.hashString + '

Return to where you came from or search for something to stream!'); + }) + .on((match) => { + //console.log("Nothing to do!"); + render(match, ""); + }) + .resolve(); +} + +function refreshElements() { + content = document.getElementById("content"); + infobar = document.getElementById("infobar"); + if (infobar === null) { + infobar = document.getElementById("infobar hidden"); + } + timer = document.getElementById("audioTimer"); + player = document.getElementById("audioPlayer"); + controls = document.getElementById("audioControls"); + metadata = document.getElementById("audioInfo"); + searchbar = document.getElementById("search"); + if (searchbar === null) { + searchbar = document.getElementById("search hidden"); + } + searchbox = document.getElementById("searchbox"); + searching = document.getElementById("searching"); + back = document.getElementById("back"); + readMore = document.getElementById("readMore"); + showLess = document.getElementById("showLess"); + moreText = document.getElementById("more"); + buttonVisibility = document.getElementById("visibility"); + notif = document.getElementById("notification"); +} + +const render = (match, content) => { + //Clear the page if we're rendering something + if (match != null && content != null) + clearPage(); + + //Set and capture the page + pageContent = content; + if (match !== null) { + pageCapture(match); + } + + //Lastly, render it + if (content !== '') { + document.querySelector("#results").innerHTML = '' + content + '
'; + } else { + document.querySelector("#results").innerHTML = ''; + } + refreshElements(); + navigo.updatePageLinks(); + + //Set the new navigation buttons + setNavButtons(); +}; + +//Button navigation for embedded clients +function setNavButtons() { + var btnBack = ''; + var btnVisibility = ''; + var btns = ''; + if (pageNum > 0 && visibility) + btns += btnBack + ' '; + btns += btnVisibility; + /* + if (pageHistory.length > 0 && pageNum < (pageHistory.length-1)) + btns += btnForward; + */ + + //Lastly, render it + document.querySelector("#nav").innerHTML = btns; + refreshElements(); + navigo.updatePageLinks(); +} + +//Toggles the visibility of the search box and the audio player +function toggleVisibility() { + if (visibility) { + //console.log("Hiding elements"); + visibility = false; + interruptNotification = true; + buttonVisibility.innerHTML = ''; + infobar.setAttribute("id", "infobar hidden"); + searchbar.setAttribute("id", "search hidden"); + + //Destroy the child elements + metadata.innerHTML = ""; + controls.innerHTML = ""; + timer.innerHTML = ""; + createdAudioPlayer = false; + searchbar.innerHTML = ""; + createdSearchBar = false; + } else { + //console.log("Showing elements"); + visibility = true; + interruptNotification = false; + buttonVisibility.innerHTML = ''; + infobar.setAttribute("id", "infobar"); + searchbar.setAttribute("id", "search"); + + //Create the child elements again + createAudioPlayer(); + createSearchBar(); + } + + setNavButtons(); +} + +function pageCapture(match) { + if (captureLock) { + //console.log("Lock prevented page capture!"); + captureLock = false; + return; + } + pageNum++; + pageHistory.splice(pageNum, (pageHistory.length - pageNum - 1)); + if (match !== null) { + lastPageUrl = match.url; + if (match.queryString !== "") + lastPageUrl += "?" + match.queryString; + } + var page = [lastPageUrl, pageObject, bgImg]; + pageHistory.push(page); + //console.log("Captured page " + (pageNum+1) + "/" + pageHistory.length + "! " + lastPageUrl); +} + +function pageRelease() { + if (pageNum-1 < 0) { + //console.log("No way backward!"); + return; + } + var oldPage = pageHistory[pageNum]; + if (oldPage == null || (oldPage[0].substring(0, 7) != "/stream" && oldPage[0].substring(0, 9) != "/download" && oldPage[0].substring(0, 6) != "/addqueue" && oldPage[0].substring(0, 11) != "/transcript")) { + pageHistory.splice(pageNum, (pageHistory.length - pageNum)); + captureLock = true; + pageNum--; + var page = pageHistory[pageNum]; + //console.log("Releasing page " + (pageNum+1) + "/" + pageHistory.length + "! " + page[0]); + pageImpose(page); + } +} + +function pageImpose(page) { + if (page == null) { + return; + } + navigo.navigate(page[0], {callHandler: true}); + lastPageUrl = page[0]; + pageObject = page[1]; + //render(null, page[1]); + setBgImg(page[2]); + navigo.updatePageLinks(); +} + +function pagePotato(match) { + pageCapture(null); + pageRelease(); +} \ No newline at end of file diff --git a/js/libremedia/libremedia-pages.js b/js/libremedia/libremedia-pages.js new file mode 100644 index 0000000..accf917 --- /dev/null +++ b/js/libremedia/libremedia-pages.js @@ -0,0 +1,253 @@ +function clearPage() { + //console.log("clearPage"); + clearTimeout(delayTimer); + delayTimer = null; + clearInterval(lyricScrollerId); //User has definitely navigated away from transcript page + lyricScrollerId = null; + resetScroll(); + lastScrollY = -1; + lastLyric = -1; + render(null, ""); //Clear the page + searching.innerHTML = ''; + searchbox.value = ''; + bgImg = ""; + resetBgImg(); + //pageObject = []; +} + +async function displayNotification(msg, timeout) { + interruptNotification = true; + notif.innerHTML = ""; + notif.style.opacity = "0.0"; + await new Promise(r => setTimeout(r, 33)); + interruptNotification = false; + notif.innerHTML = msg; + for (let i = 1; i < 10; i++) { + if (interruptNotification) + return; + notif.style.opacity = "0." + i; + await new Promise(r => setTimeout(r, 33)); + } + if (interruptNotification) + return; + notif.style.opacity = "1"; + await new Promise(r => setTimeout(r, timeout)); + for (let i = 9; i >= 0; i--) { + if (interruptNotification) + return; + notif.style.opacity = "0." + i; + await new Promise(r => setTimeout(r, 33)); + } + if (interruptNotification) + return; + notif.innerHTML = ""; +} + +async function displaySearch(match) { + var q = match.params.q; + //console.log("Search: " + q); + document.querySelector("#searching").innerHTML = '🔎'; + clearTimeout(delayTimer); + delayTimer = setTimeout(function() { + var results = v1GetSearch(q).object; + pageObject = results.streams; + + var html = ""; + //Streams + if (results.streams != null) + html += tblStreams(results.streams); + + //Creators + if (results.creators != null) + html += tblCreators(results.creators); + + //Albums + if (results.albums != null) + html += tblAlbums(results.albums); + + if (delayTimer == null) + return; + render(match, html); + searchbox.focus(); + searchbox.value = q; + }, 1000); +} + +async function displayCreator(match) { + if (match.params == null) { + pageRelease(); + return; + } + var uri = match.params.uri; + //console.log("Creator: " + uri); + + var creator = v1GetObject(uri); + + if (creator.object.artworks != null && creator.object.artworks.length > 0) { + const bestbg = creator.object.artworks[creator.object.artworks.length-1]; + setBgImg(bestbg.url); + } + + var html = ''; + if (creator.object.genres != null && creator.object.genres.length > 0) { + html += '
'; + for (let i = 0; i < creator.object.genres.length; i++) { + html += creator.object.genres[i] + '
'; + } + html += '
'; + } + html += ''; + if (creator.object.description != null && creator.object.description.length > 0) { + const bio = creator.object.description + .replace(/\r\n|\r|\n/gim, '
') // linebreaks + .replace(/\[([^\[]+)\](\(([^)]*))\)/gim, '$1'); // anchor tags + const splitBio = bio.split(" "); + html += ''; + if (splitBio.length > 20) { + smallBioView = splitBio.slice(0, 20).join(" "); + smallBioHidden = smallBioView + " " + splitBio.slice(20, splitBio.length).join(" "); + html += '

' + smallBioView + ' ...
(tap to show more)

' + smallBioHidden + '
(tap again to show less)

'; + } else { + html += '
' + bio + '
'; + } + html += ''; + } + + //Top tracks + if (creator.object.topStreams != null && creator.object.topStreams.length > 0) { + pageObject = creator.object.topStreams; + html += tblStreamsTop(creator.object.topStreams); + } + + //Albums + if (creator.object.albums != null && creator.object.albums.length > 0) { + html += tblAlbums(creator.object.albums); + } + + //Appearances + if (creator.object.appearances != null && creator.object.appearances.length > 0) { + html += tblAppearances(creator.object.appearances); + } + + //Singles + if (creator.object.singles != null && creator.object.singles.length > 0) { + html += tblSingles(creator.object.singles); + } + + //Related + if (creator.object.related != null && creator.object.related.length > 0) { + html += tblRelated(creator.object.related); + } + + render(match, html); +} + +async function displayAlbum(match) { + if (match.params == null) { + pageRelease(); + return; + } + var uri = match.params.uri; + //console.log("Album: " + uri); + + var album = v1GetObject(uri); + + if (album.object.artworks != null && album.object.artworks.length > 0) { + var selbg = album.object.artworks.length-1; + if (selbg > 4) { + selbg = 4; + } + const bestbg = album.object.artworks[selbg]; + setBgImg(bestbg.url); + } + + var html = ''; + if (album.object.creators != null) { + html += ''; + } + html += ''; + + //Discs + for (let i = 0; i < album.object.discs.length; i++) { + html += 'Disc ' + (i + 1) + 'Streams (' + album.object.discs[i].streams.length + ')🕑'; + for (let j = 0; j < album.object.discs[i].streams.length; j++) { + pageObject.push(album.object.discs[i].streams[j].object); + html += tblStreamAlbum(album.provider, album.object.discs[i].streams[j].object, j+1); + } + html += '
'; + } + + render(match, html); +} + +async function displayTranscript(match) { + clearInterval(lyricScrollerId); + nowPlayingTiming = []; + + var uri = ""; + if (nowPlaying != null) { + uri = nowPlaying.uri; + } + if (match != null && match.params != null) { + uri = match.params.uri; + } + if (uri == "") { + pageRelease(); + return; + } + //console.log("Transcript: " + uri); + + var stream = nowPlaying; + var isNowPlaying = true; + if (stream == null || uri != stream.uri) { + isNowPlaying = false; + stream = v1GetObject(uri).object; + } + //console.log("Transcript: Is now playing? " + isNowPlaying); + setBgStream(stream); + + if (stream.transcript != null && stream.transcript.lines != null && stream.transcript.lines.length > 0) { + var colspan = 0; + var html = ''; + if (stream.creators != null) { + html += ''; + colspan++; + } + if (stream.album != null) { + if (colspan == 0) { + html += ''; + } + html += ''; + colspan++; + } + if (colspan > 0) { + html += ''; + } + colspan++; + html += ''; + + var lines = stream.transcript.lines; + //console.log("Building transcript for " + lines.length + " lines, should add timings? " + isNowPlaying); + html += '
'; + for (let i = 0; i < lines.length; i++) { + if (lines[i].text != null) { + html += '
' + lines[i].text + '
'; + } else { + html += '
'; + } + } + html += '
'; + render(match, html); + + if (isNowPlaying) { + //console.log("Now playing, loading transcript timings"); + loadTranscriptTimings(stream); + } + } else { + render(match, "No transcript for this stream!"); + } +} \ No newline at end of file diff --git a/js/libremedia/libremedia-playback.js b/js/libremedia/libremedia-playback.js new file mode 100644 index 0000000..e9bc602 --- /dev/null +++ b/js/libremedia/libremedia-playback.js @@ -0,0 +1,338 @@ +function createAudioPlayer() { + if (createdAudioPlayer) { + return; + } + createdAudioPlayer = true; + + controls.innerHTML = ""; + buttonTranscript = document.createElement("button"); + buttonTranscript.setAttribute("id", "btnTranscript"); + buttonTranscript.innerHTML = ''; + buttonPrev = document.createElement("button"); + buttonPrev.setAttribute("id", "btnPrv"); + buttonPrev.innerHTML = ''; + buttonPrev.addEventListener("click", playPrev); + buttonPP = document.createElement("button"); + buttonPP.setAttribute("id", "btnPP"); + buttonPP.innerHTML = play; + if (!player.paused) { + buttonPP.innerHTML = pause; + } + buttonNext = document.createElement("button"); + buttonNext.setAttribute("id", "btnNxt"); + buttonNext.innerHTML = ''; + buttonNext.addEventListener("click", playNext); + //buttonDownload = document.createElement("button"); + //buttonDownload.setAttribute("id", "btnDownload"); + buttonRepeat = document.createElement("button"); + buttonRepeat.setAttribute("id", "btnRepeat"); + buttonRepeat.innerHTML = ''; + buttonRepeat.addEventListener("click", toggleRepeat); + controls.appendChild(buttonTranscript); + controls.appendChild(buttonPrev); + controls.appendChild(buttonPP); + controls.appendChild(buttonNext); + //controls.appendChild(buttonDownload); + controls.appendChild(buttonRepeat); + + //console.log("Setting audio events"); + document.getElementById("audioPlayer").addEventListener("ended", playNextEvent); + audioInit(); + + //Rebuild the audio player's metadata if something was playing + if (nowPlaying != null) { + const stream = nowPlaying; + const creator = ''; + const albumObj = v1GetObject(stream.album.object.uri).object; + const album = '
' + albumObj.name + ''; + //const number = stream.number; //TODO: Return track number of album in the API + const name = ''; + + metadata.innerHTML = name + creator + album; + if (albumObj.datetime != null) + metadata.innerHTML += '
(' + albumObj.datetime + ')
'; + //buttonDownload.innerHTML = ''; + buttonTranscript.innerHTML = ''; + timer.innerHTML = secondsTimestamp(player.currentTime) + " / " + secondsTimestamp(stream.duration); + navigo.updatePageLinks(); + } else { + timer.innerHTML = "Waiting to stream..."; + btnPP.innerHTML = loading; + } +} + +function updateAudioPlayer(streamURI) { + //console.log("Updating audio player " + lastPageUrl); + + if (streamURI == null) { + player.src = ""; + player.duration = 0; + resetBgImg(); + return; + } + + const stream = v1GetObject(streamURI).object; + nowPlaying = stream; + const creator = ''; + const albumObj = v1GetObject(stream.album.object.uri).object; + const album = '
' + albumObj.name + ''; + //const number = stream.number; //TODO: Return track number of album in the API + const name = ''; + const duration = stream.duration / 1000.0; + + displayNotification("Now playing:" + name + creator + album, 5000); + metadata.innerHTML = name + creator + album; + if (albumObj.datetime != null) + metadata.innerHTML += '
(' + albumObj.datetime + ')
'; + player.src = "/v1/stream/" + streamURI; + player.duration = duration; + //buttonDownload.innerHTML = ''; + buttonTranscript.innerHTML = ''; + timer.innerHTML = "0:00 / " + secondsTimestamp(duration); + navigo.updatePageLinks(); + + if (lastPageUrl == "transcript" || lastPageUrl == "transcript?uri=" + stream.uri) { + displayTranscript(null); + } + setBgStream(stream); +} + +function playStream(match) { + if (match.params == null) { + pagePotato(match); + return; + } + var uri = match.params.uri; + //console.log("Now playing: " + uri); + updateAudioPlayer(uri); + queueSet(pageObject); + audioInit(); + audioPP(); + pagePotato(match); +} + +//Replaces the page queue with a new one, skipping forward to nowPlaying and preserving the user's up next queue +function queueSet(pageQueue) { + //console.log("skipping ahead in queue"); + for (let i = 0; i < pageQueue.length; i++) { + if (pageQueue[i].uri == nowPlaying.uri) { + var pageQueueFront = pageQueue.slice(0, i); + //console.log(pageQueueFront); + var pageQueueBack = pageQueue.slice(i+1, pageQueue.length); + //console.log(pageQueueBack); + pageQueue = pageQueueBack.concat(pageQueueFront); + //console.log(pageQueue); + queueLeft = pageQueueBack.length; + break; + } + } + //console.log(queueLeft); + + if (queueEnd < 0 || queueStart < 0) { + //console.log("no up next queue, setting page queue as-is"); + queue = pageQueue; + return; + } + + //Build the new queue, pushing the up next section to the front + var newQueue = []; + if (queueStart == 0) { + newQueue = queue.slice(0, queueEnd+1); + } else { + newQueue = queue.slice(queueStart, queue.length-queueStart); + if (queueEnd <= queueStart) { + newQueue = newQueue.concat(queue.slice(0, queueEnd+1)); + } + } + queueStart = 0; + queueEnd = newQueue.length-1; + queue = newQueue.concat(pageQueue); + queueLeft += newQueue.length; +} + +//Adds to the end of the up next queue +function queueAdd(stream) { + //console.log("queueAdd-"); + //console.log(queue); + queueLeft++; + if (queueEnd < 0 || queueStart < 0) { + queue.splice(0, 0, stream); + queueStart = 0; + queueEnd = 0; + return; + } + queue.splice(queueEnd+1, 0, stream); + queueEnd++; + //console.log("queueAdd+"); + //console.log(queue); +} + +//Navigo wrapper to add an entry to the queue +function queueAddStream(match) { + if (match.params == null) { + pageRelease(); + return; + } + var uri = match.params.uri; + //console.log("Queue: " + uri); + var stream = { + "uri": uri, + } + queueAdd(stream); + pagePotato(match); + displayNotification("Added to stream!", 5000); +} + +//Adds to immediately play next, but logically treats it as the end of the up next queue +function queueNext(stream) { + //console.log("queueNext-"); + //console.log(queue); + queue.splice(0, 0, stream); + if (queueEnd == queue.length-1) { + queueEnd = 0; + } else { + queueEnd++; + } + //console.log("queueNext+"); + //console.log(queue); +} + +//Clears the up next queue +function queueClear() { + if (queueEnd < 0 || queueStart < 0) { + return; + } + if (queueEnd > queueStart) { + queue.splice(queueStart, queueEnd+1); + } else { + queue.splice(queueStart, queue.length-queueStart-1); + queue.splice(0, queueEnd+1); + } + queueStart = -1; + queueEnd = -1; +} + +//Skips to the next stream in the queue +function playNext() { + if (nowPlaying == null) { + return; + } + clearInterval(lyricScrollerId); + lastScrollY = -1; + lastLyric = -1; + nowPlayingTiming = []; + audioPause(); //Pause audio no matter what + + //console.log("playNext-"); + //console.log(queue); + + //If repeating now playing, just restart the stream - user should turn it off to advance + if (repeat == 2) { + //updateAudioPlayer(nowPlaying.uri); //TODO: Literally just restart the stream, no need to do all this reloading nonsense but the function already exists and I haven't Googled it yet + audioPP(); + return; + } + + //Migrate nowPlaying to end of queue + queue.push(nowPlaying); + + //If end of queue, we're done! + if (queueLeft == 0) { + //console.log("Nothing up next!"); + updateAudioPlayer(null); //TODO: Destroy audio player + return; + } + if (repeat == 0) + queueLeft--; + + //Migrate next queue entry into nowPlaying + nowPlaying = queue[0]; + queue.splice(0, 1); + + if (queueEnd > -1 && queueStart > -1) { + if (queueStart == 0) { + if (repeat == 1) { + queueStart = queue.length; + } + } + queueStart--; + queueEnd--; + } + + updateAudioPlayer(nowPlaying.uri); + audioPP(); + + //console.log("playNext+"); + //console.log(queue); +} + +//Wrapper for playNext +function playNextEvent(event) { + //console.log("Stream finished! Playing next..."); + playNext(); +} + +//Returns to the previous stream in the queue, or restarts the song +function playPrev() { + if (nowPlaying == null) { + return; + } + clearInterval(lyricScrollerId); + lastScrollY = -1; + lastLyric = -1; + nowPlayingTiming = []; + audioPause(); //Pause audio no matter what + + //console.log("playPrev-"); + //console.log(queue); + + //If repeating now playing, just restart the stream - user should turn it off to advance + if (repeat == 2) { + //updateAudioPlayer(nowPlaying.uri); //TODO: Literally just restart the stream, no need to do all this reloading nonsense but the function already exists and I haven't Googled it yet + audioPP(); + return; + } + + //Migrate nowPlaying to front of queue + queue.splice(0, 0, nowPlaying); + + //Migrate last queue entry into nowPlaying + nowPlaying = queue[queue.length-1]; + queue.splice(queue.length-1, 1); + + if (queueEnd > -1 && queueStart > -1) { + if (queueStart == queue.length-1) { + queueStart = -1; + } + queueStart++; + queueEnd++; + } + + updateAudioPlayer(nowPlaying.uri); + audioPP(); + + //console.log("playPrev+"); + //console.log(queue); +} + +//Toggles the current repeat mode +function toggleRepeat() { + queueLeft = queue.length; //Reset the amount of queue entries left to reflect the remaining queue, even if skipped ahead + switch (repeat) { + case 0: + //console.log("Repeating queue"); + repeat = 1; + buttonRepeat.innerHTML = ''; + break; + case 1: + //console.log("Repeating now playing"); + repeat = 2; + buttonRepeat.innerHTML = ''; + break; + case 2: + //console.log("Not repeating"); + repeat = 0; + buttonRepeat.innerHTML = ''; + break; + } +} diff --git a/js/libremedia/libremedia-search.js b/js/libremedia/libremedia-search.js new file mode 100644 index 0000000..ed132d7 --- /dev/null +++ b/js/libremedia/libremedia-search.js @@ -0,0 +1,36 @@ +function createSearchBar() { + if (createdSearchBar) { + return; + } + createdSearchBar = true; + + searchbar.innerHTML = ""; + searchbox = document.createElement("input"); + searchbox.setAttribute("id", "searchbox"); + searchbox.setAttribute("type", "text"); + searchbox.setAttribute("placeholder", "🔎 creator, stream, album ..."); + notif = document.createElement("div"); + notif.setAttribute("id", "notification"); + searching = document.createElement("div"); + searching.setAttribute("id", "searching"); + searchbar.appendChild(searchbox); + searchbar.appendChild(notif); + searchbar.appendChild(searching); + + $("#searchbox").keyup(function() { + refreshQuery(); + if (previousQuery == query) { + return; + } + previousQuery = query; + if (query == "") { + navigo.navigate("/"); + } else { + navigo.navigate("search?q=" + query); + } + }); +} + +function refreshQuery() { + query = $("#searchbox").val(); +} \ No newline at end of file diff --git a/js/libremedia/libremedia-tables.js b/js/libremedia/libremedia-tables.js new file mode 100644 index 0000000..3b5e7b5 --- /dev/null +++ b/js/libremedia/libremedia-tables.js @@ -0,0 +1,196 @@ +function tblStream(provider, stream) { + //console.log(stream); + var refresh = 'refresh to try again'; + var html = '
'; + if (stream.creators != null) { + html += ''; + } else { + html += refresh; + } + html += '
'; + if (stream.album != null) { + html += ''; + } else { + html += refresh; + } + html += '' + secondsTimestamp(stream.duration) + '
'; + if (stream.transcript != null) { + html += ' '; + } + html += ' '; + html += '
'; + + if (stream.album.object.artworks != null) { + var selbg = stream.album.object.artworks.length-1; + if (selbg > 4) { + selbg = 4; + } + const bestbg = stream.album.object.artworks[selbg]; + html = '' + html + ''; + } else { + html = '' + html + ''; + } + + return html; +} +function tblStreams(streams) { + html = 'Streams🕑' + for (let i = 0; i < streams.length; i++) { + html += tblStream(streams[i].provider, streams[i].object); + } + return html; +} + +function tblCreator(provider, creator) { + //console.log(creator); + var html = ""; + html += ''; + + if (creator.artworks != null) { + var selbg = creator.artworks.length-1; + if (selbg > 4) { + selbg = 4; + } + const bestbg = creator.artworks[selbg]; + html = '' + html + ''; + } else { + html = '' + html + ''; + } + + return html; +} +function tblCreators(creators) { + var html = 'Creators'; + for (let i = 0; i < creators.length; i++) { + const creator = creators[i]; + html += tblCreator(creator.provider, creator.object); + } + return html; +} +function tblRelated(creators) { + var html = 'Related'; + for (let i = 0; i < creators.length; i++) { + const creator = creators[i]; + html += tblCreator(creator.provider, creator.object); + } + return html; +} +function tblStreamTop(provider, stream) { + //console.log(stream); + var refresh = 'refresh to try again'; + var html = '
'; + if (stream.album != null) { + html += ''; + } else { + html += refresh; + } + html += '' + secondsTimestamp(stream.duration) + '
'; + if (stream.transcript != null) { + html += ' '; + } + html += ' '; + html += '
'; + + if (stream.album.object.artworks != null) { + var selbg = stream.album.object.artworks.length-1; + if (selbg > 4) { + selbg = 4; + } + const bestbg = stream.album.object.artworks[selbg]; + html = '' + html + ''; + } else { + html = '' + html + ''; + } + + return html; +} +function tblStreamsTop(streams) { + var html = 'Top Streams🕑'; + for (let i = 0; i < streams.length; i++) { + html += tblStreamTop(streams[i].provider, streams[i].object); + } + return html; +} + +function tblAlbum(provider, album) { + //console.log(album); + var html = ''; + html += ''; + + if (album.artworks != null) { + var selbg = album.artworks.length-1; + if (selbg > 4) { + selbg = 4; + } + const bestbg = album.artworks[selbg]; + html = '' + html + ''; + } else { + html = '' + html + ''; + } + + return html; +} +function tblAlbums(albums) { + var html = 'Albums'; + for (let i = 0; i < albums.length; i++) { + const album = albums[i]; + html += tblAlbum(album.provider, album.object); + } + return html; +} +function tblSingles(albums) { + var html = 'Singles'; + for (let i = 0; i < albums.length; i++) { + const album = albums[i]; + html += tblAlbum(album.provider, album.object); + } + return html; +} +function tblAppearances(albums) { + var html = 'Appears On'; + for (let i = 0; i < albums.length; i++) { + const album = albums[i]; + html += tblAlbum(album.provider, album.object); + } + return html; +} +function tblStreamAlbum(provider, stream, number) { + var refresh = 'refresh to try again'; + var html = '
'; + if (stream.name !== "") { + html += stream.name + ''; + } else { + html += refresh + ''; + } + html += '
' + secondsTimestamp(stream.duration) + '
'; + if (stream.transcript != null) { + html += ' '; + } + html += ' '; + html += '
'; + + if (stream.album.object.artworks != null) { + var selbg = stream.album.object.artworks.length-1; + if (selbg > 4) { + selbg = 4; + } + const bestbg = stream.album.object.artworks[selbg]; + html = '' + html + ''; + } else { + html = '' + html + ''; + } + + return html; +} \ No newline at end of file diff --git a/js/libremedia/libremedia-transcript.js b/js/libremedia/libremedia-transcript.js new file mode 100644 index 0000000..cfb2a82 --- /dev/null +++ b/js/libremedia/libremedia-transcript.js @@ -0,0 +1,116 @@ +async function loadTranscriptTimings(stream) { + clearInterval(lyricScrollerId); + nowPlayingTiming = []; + + if (stream.transcript != null && stream.transcript.lines != null && stream.transcript.lines.length > 0 && nowPlaying != null && nowPlaying.uri == stream.uri && (lastPageUrl == "transcript" || lastPageUrl == "transcript?uri=" + stream.uri)) { + //console.log("Loading transcript timings for " + stream.uri); + + var lines = stream.transcript.lines; + if (lines[0].startTimeMs > 0) + nowPlayingTiming.push([0, ""]); + + for (let i = 0; i < lines.length; i++) { + var timing = [lines[i].startTimeMs, lines[i].text]; + nowPlayingTiming.push(timing); + } + + lastScrollY = window.scrollY; + lastLyric = -1; + lyricScroller(); + lyricScrollerId = setInterval(lyricScroller, 100); + //console.log("Spawned auto-scroller: " + lyricScrollerId); + } else { + //console.log("Failed to match case for loading transcript timings " + lastPageUrl); + } +} + +function lyricScroll(lyric) { + //console.log("Scrolling to lyric " + lyric); + if (nowPlayingTiming.length > 0) { + var lyricLine; + if (lyric >= nowPlayingTiming.length) { + lyricLine = document.getElementById("lyricEnd"); + } else { + lyricLine = document.getElementById("lyric" + lyric); + } + if (lyricLine == null) { + //console.log("Failed to find lyric line!"); + return; + } + //console.log("Scrolling to lyric " + lyric); + var rect = lyricLine.getBoundingClientRect(); + var absoluteTop = rect.top + window.pageYOffset; + var middle = absoluteTop - (window.innerHeight / 2); + window.scrollTo(0, middle + ((rect.bottom - rect.top) / 2)); + lastScrollY = window.scrollY; + lastLyric = lyric; + } else { + //console.log("Now playing timings empty"); + clearInterval(lyricScrollerId); + nowPlayingTiming = []; + } +} + +function lyricScroller() { + if (nowPlaying == null) { + //console.log("Clearing scroller because nothing is playing"); + clearInterval(lyricScrollerId); + return; + } + + if (window.scrollY != lastScrollY && lastLyric > -1) { + //console.log("Clearing scroller because user scrolled"); + clearInterval(lyricScrollerId); + return; + } + + if (player.paused) { + //console.log("Auto-scroll is paused"); + return; + } + //console.log("Auto-scroll is not paused"); + + if (nowPlayingTiming.length > 0) { + var curTime = player.currentTime*1000; + var lyric = lastLyric; + if (lastLyric > -1) { + for (let i = 0; i < nowPlayingTiming.length; i++) { + var line = nowPlayingTiming[i]; + if (line == null) { + continue; + } + if (curTime > line[0]) { + lyric = i; + continue; + } + break; + } + } else { + lyric = 0; + } + //console.log("Trying to scroll to lyric " + lyric); + lyricScroll(lyric); + } +} + +function lyricSeek(lyric) { + if (nowPlayingTiming.length == 0) { + return; + } + //console.log("Wanting to seek to lyric " + lyric); + var startTimeMs = 0; + if (lyric >= nowPlayingTiming.length) { + startTimeMs = nowPlaying.duration * 1000; + } else if (lyric >= 0) { + var line = nowPlayingTiming[lyric]; + startTimeMs = line[0]; + } + var startTime = Math.floor(startTimeMs/1000); + //console.log("Seeking to timestamp " + startTime); + player.currentTime = startTime; + clearInterval(lyricScrollerId); + lastScrollY = window.scrollY; + lyricScroll(lyric); + lyricScrollerId = setInterval(lyricScroller, 100); + //console.log("Spawned auto-scroller: " + lyricScrollerId); +} \ No newline at end of file diff --git a/js/libremedia/libremedia-utils.js b/js/libremedia/libremedia-utils.js new file mode 100644 index 0000000..215a4b2 --- /dev/null +++ b/js/libremedia/libremedia-utils.js @@ -0,0 +1,63 @@ +function textExpander() { + if (elementHidden(readMore)) { + elementShow(readMore); + elementHide(showLess); + elementHide(moreText); + } else { + elementHide(readMore); + elementShow(showLess); + elementShow(moreText); + } +} + +function elementHide(element) { + element.style.display = "none"; +} +function elementShow(element) { + element.style.display = "inline"; +} +function elementHidden(element) { + return (element.style.display === "none"); +} +function elementVisible(element) { + return !elementHidden(element); +} + +//Reset the scroll position to the top left of the document +function resetScroll() { + if (window.scrollY) { + window.scroll(0, 0); + } +} + +function iconProvider(provider) { + switch (provider) { + case "spotify": + return ''; + case "tidal": + return ''; + } + return ''; +} + +function sanitizeWhitespace(input) { + var output = $('').text(input).html(); + output = output.replace(" ", "+"); + return output; +} + +function secondsTimestamp(seconds) { + // Hours, minutes and seconds + var hrs = ~~(seconds / 3600); + var mins = ~~((seconds % 3600) / 60); + var secs = ~~seconds % 60; + + // Output like "1:01" or "4:03:59" or "123:03:59" + var ret = ""; + if (hrs > 0) { + ret += "" + hrs + ":" + (mins < 10 ? "0" : ""); + } + ret += "" + mins + ":" + (secs < 10 ? "0" : ""); + ret += "" + secs; + return ret; +} \ No newline at end of file diff --git a/js/libremedia/libremedia.js b/js/libremedia/libremedia.js new file mode 100644 index 0000000..03547bb --- /dev/null +++ b/js/libremedia/libremedia.js @@ -0,0 +1,67 @@ +//Elements +var content; //Wrapper for everything +var infobar; +var timer; +var player; +var metadata; +var download; +var searchbar; +var searchbox; +var searching; +var back; +var readMore; +var showLess; +var moreText; +var buttonPrev; +var buttonPP; +var buttonNext; +var buttonDownload; +var buttonTranscript; +var buttonRepeat; +var buttonVisibility; +var notif; + +var visibility = true; //Used when toggling visibility of the search box and audio player + +//Playback management +var queue = []; //Holds a list of queued streams, starting with the user's queue, followed by the queue of the current page +var queueStart = -1; //The index of the first user-added stream in the queue +var queueEnd = -1; //The index of the last user-added stream in the queue +var queueLeft = 0; //The total amount of streams left to play before the end of the queue +var nowPlaying; //The stream currently loaded into the audio player +var nowPlayingTiming = []; //The current stream's transcript timings for seeking and following along +var shuffle = false; +var repeat = 0; //0=no repeat, 1=repeat queue, 2=repeat now playing +var lyricScrollerId; //Holds an id returned by setInterval, used to clear timer on page clear +var lastScrollY = -1; //The last recorded Y-axis scroll position, used to cancel auto-scroller +var lastLyric = -1; //The last recorded lyric that was auto-scrolled to, -1 means hasn't been scrolled and 0 means beginning of stream + +//Search results +var query = ""; +var previousQuery = ""; +var delayTimer; + +//Single page routing with navigo +var navigo; +var pageHistory = []; +var pageNum = -1; +var pageContent = ""; +var pageObject = []; +var bgImg = ""; + +var captureLock = false; //Used to prevent captures on scripted pages that don't render any content +var lastPageUrl = "/"; //Used to be globally aware of the current page + +var createdSearchBar = false; +var createdAudioPlayer = false; +var interruptNotification = false; + +$(document).ready(function() { + console.log("Setting up libremedia..."); + refreshElements(); + createAudioPlayer(); + createSearchBar(); + refreshQuery(); + navigoResolve(); + console.log("Finished constructing libremedia instance!"); +}); \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..4abffdf --- /dev/null +++ b/main.go @@ -0,0 +1,412 @@ +package main + +/* + Title: libremedia + Version: 1.0 + Author: Joshua "JoshuaDoes" Wickings + License: GPL v3 + + Failure to comply with this license will result in legal penalties as permitted by copyright law. +*/ + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/eolso/librespot-golang/librespot/utils" +) + +type exporterr struct { + Error string `json:"error"` +} + +type valid struct { + Valid bool `json:"valid"` +} + +var ( + service *Service +) + +var ( + //Trace logs trace info + Trace *log.Logger + //Info logs information + Info *log.Logger + //Warning logs warnings + Warning *log.Logger + //Error logs errors + Error *log.Logger +) + +func initLogging( + traceHandle io.Writer, + infoHandle io.Writer, + warningHandle io.Writer, + errorHandle io.Writer) { + + Trace = log.New(traceHandle, + "TRACE: ", + log.Ldate|log.Ltime|log.Lshortfile) + + Info = log.New(infoHandle, + "INFO: ", + log.Ldate|log.Ltime|log.Lshortfile) + + Warning = log.New(warningHandle, + "WARNING: ", + log.Ldate|log.Ltime|log.Lshortfile) + + Error = log.New(errorHandle, + "ERROR: ", + log.Ldate|log.Ltime|log.Lshortfile) +} + +func main() { + initLogging(os.Stderr, os.Stdout, os.Stderr, os.Stderr) + + var err error + + //Open the configuration + configFile, err := os.Open("config.json") + if err != nil { + Error.Println("need configuration") + return + } + defer configFile.Close() + + //Load the configuration into memory + configParser := json.NewDecoder(configFile) + if err = configParser.Decode(&service); err != nil { + Error.Println("error loading configuration: " + fmt.Sprintf("%v", err)) + return + } + + err = service.Login() + if err != nil { + Error.Println("error logging in: " + fmt.Sprintf("%v", err)) + } + + //libremedia API v1 + http.HandleFunc("/v1/", v1Handler) + http.HandleFunc("/v1/stream/", v1StreamHandler) + http.HandleFunc("/v1/download/", v1DownloadHandler) + + //Built-in utilities that may not be recreatable in some circumstances + http.HandleFunc("/util/gid2id/", gid2id) + + //Web interfaces + http.HandleFunc("/", webHandler) + + Warning.Fatal(http.ListenAndServe(service.HostAddr, nil)) +} + +func v1Handler(w http.ResponseWriter, r *http.Request) { + uri := r.URL.Path[4:] + obj := GetObject(uri) + if obj == nil { + jsonWriteErrorf(w, 404, "no matching object") + return + } + if !obj.Expanded && !obj.Expanding { + go obj.Expand() + } + jsonWrite(w, obj) +} + +func v1DownloadHandler(w http.ResponseWriter, r *http.Request) { + settings := r.URL.Query() + + path := strings.Split(r.URL.Path[13:], "?") + objectStream := GetObjectLive(path[0]) + if objectStream == nil { + jsonWriteErrorf(w, 404, "no matching stream object") + return + } + if objectStream.Type != "stream" { + jsonWriteErrorf(w, 404, "no matching stream object") + return + } + stream := objectStream.Stream() + if stream == nil { + jsonWriteErrorf(w, 500, "unable to process stream object") + return + } + formatCfg := settings.Get("format") + if formatCfg == "" { + formatCfg = "0" + } + formatNum, err := strconv.Atoi(formatCfg) + if err != nil { + jsonWriteErrorf(w, 500, "libremedia: format selection unavailable") + return + } + err = service.Download(w, r, stream, formatNum) + if err != nil { + if settings.Get("format") != "" { + jsonWriteErrorf(w, 500, "libremedia: format selection unavailable for matched stream object") + return + } + if len(stream.Formats) == 0 { + jsonWriteErrorf(w, 404, "libremedia: no formats available to select from for matched stream object") + return + } + streamed := false + for i := formatNum; i < len(stream.Formats); i++ { + if stream.Formats[i] != nil { + Trace.Println("Selecting format " + stream.Formats[i].Name + " automatically") + err = service.Download(w, r, stream, i) + if err != nil { + continue + } + streamed = true + break + } + } + if !streamed { + jsonWriteErrorf(w, 500, "libremedia: all formats available to select from matched stream object were null") + return + } + } + return +} + +func v1StreamHandler(w http.ResponseWriter, r *http.Request) { + settings := r.URL.Query() + + path := strings.Split(r.URL.Path[11:], "?") + objectStream := GetObjectLive(path[0]) + if objectStream == nil { + jsonWriteErrorf(w, 404, "no matching stream object") + return + } + if objectStream.Type != "stream" { + jsonWriteErrorf(w, 404, "no matching stream object") + return + } + stream := objectStream.Stream() + if stream == nil { + jsonWriteErrorf(w, 500, "unable to process stream object") + return + } + formatCfg := settings.Get("format") + if formatCfg == "" { + formatCfg = "0" + } + formatNum, err := strconv.Atoi(formatCfg) + if err != nil { + jsonWriteErrorf(w, 500, "libremedia: format selection unavailable") + return + } + + err = service.Stream(w, r, stream, formatNum) + if err != nil { + errMsg := err.Error() + if strings.Contains(errMsg, "broken pipe") || strings.Contains(errMsg, "connection reset") { + return //The stream was successful, but interrupted + } + if settings.Get("format") != "" { + jsonWriteErrorf(w, 500, "libremedia: format selection unavailable for matched stream object") + return + } + if len(stream.Formats) == 0 { + jsonWriteErrorf(w, 404, "libremedia: no formats available to select from for matched stream object") + return + } + streamed := false + for i := formatNum; i < len(stream.Formats); i++ { + if stream.Formats[i] != nil { + Trace.Println("Selecting format " + stream.Formats[i].Name + " automatically") + err = service.Stream(w, r, stream, i) + if err != nil { + errMsg = err.Error() + if strings.Contains(errMsg, "broken pipe") || strings.Contains(errMsg, "connection reset") { + return //The stream was successful, but interrupted + } + continue + } + streamed = true + break + } + } + if !streamed { + jsonWriteErrorf(w, 500, "libremedia: all formats available to select from matched stream object were null") + return + } + } + return +} + +func webHandler(w http.ResponseWriter, r *http.Request) { + var file *os.File + var err error + + low := len(r.URL.Path) - 4 //favicon.ico n + high := len(r.URL.Path) //favicon.ico o + if r.URL.Path == "/" { + Warning.Println("Serving HTML: /") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + file, err = os.Open("index.html") + if err != nil { + panic("need index.html!") + } + } else { + file, err = os.Open(string(r.URL.Path[1:])) + if err != nil { + Warning.Println("Serving 404! " + r.URL.Path) + w.WriteHeader(404) + return + } + if string(r.URL.Path[low-1:high]) == ".html" { + Warning.Println("Serving HTML:", r.URL.Path) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + } else if string(r.URL.Path[low:high]) == ".css" { + Warning.Println("Serving CSS:", r.URL.Path) + w.Header().Set("Content-Type", "text/css") + } else if string(r.URL.Path[low+1:high]) == ".js" { + Warning.Println("Serving JS:", r.URL.Path) + w.Header().Set("Content-Type", "text/javascript") + } else { + Warning.Println("Serving content:", r.URL.Path) + } + } + defer file.Close() + + http.ServeContent(w, r, "", time.Time{}, file) +} + +type expid struct { + ID string `json:"id"` +} + +func gid2id(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + remote := getRemote(r) + + Info.Println(remote, "gid2id:", "Getting gid") + + gid := "" + for key, values := range r.URL.Query() { + for _, value := range values { + if key == "gid" { + gid = value + break + } + } + } + if gid == "" { + Error.Println(remote, "gid2id:", "No gid specified") + jsonWriteErrorf(w, 405, "endpoint requires gid") + return + } + gid = strings.ReplaceAll(gid, " ", "+") + + str, err := base64.StdEncoding.DecodeString(gid) + if err != nil { + Error.Println(remote, "gid2id:", "Invalid gid", gid, ": ", err) + jsonWriteErrorf(w, 405, "invalid gid %s: %v", gid, err) + } + + id := utils.ConvertTo62(str) + + Info.Println(remote, "gid2id:", "Converted", gid, "to", id) + jsonWrite(w, &expid{ID: id}) +} + +/*func suggestHandler(w http.ResponseWriter, r *http.Request) { + remote := getRemote(r) + + Info.Println(remote, "suggest:", "Checking passkey") + + allowed := false + suggestQuery := "" + for key, values := range r.URL.Query() { + for _, value := range values { + switch key { + case "pass": + if value == passkey { + allowed = true + } + case "query": + suggestQuery = value + } + } + } + if allowed == false { + Error.Println(remote, "suggest:", "Invalid passkey") + jsonWriteErrorf(w, 401, "invalid pass key") + return + } + + Info.Println(remote, "suggest:", "Sending suggest for query \""+suggestQuery+"\"") + displaySuggest(w, suggestQuery) +}*/ + +func jsonWrite(w http.ResponseWriter, data interface{}) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + //Allow marshalling special cases + switch typedData := data.(type) { + case error: + json, err := json.Marshal(&exporterr{Error: typedData.Error()}) + if err != nil { + Error.Println("Could not marshal data [ ", err, " ]:", data) + jsonWriteErrorf(w, 500, "could not prep data") + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(json) + Error.Printf("Sent error: %v\n", typedData.Error()) + default: + json, err := json.Marshal(data) + if err != nil { + Error.Println("Could not marshal data [ ", err, " ]:", data) + jsonWriteErrorf(w, 500, "could not prep data") + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(json) + } +} +func jsonWriteErrorf(w http.ResponseWriter, statusCode int, error string, data ...interface{}) { + errMsg := fmt.Errorf(error, data...) + + json, err := json.Marshal(&exporterr{Error: errMsg.Error()}) + if err != nil { + w.WriteHeader(500) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + Error.Println("Could not marshal data [ ", err, " ]:", data) + w.Write([]byte("500 Internal Server Error")) + return + } + + w.WriteHeader(statusCode) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Write(json) + Error.Printf("Sent error %d: %v\n", statusCode, errMsg) +} + +func getRemote(r *http.Request) string { + userAgent := r.Header.Get("User-Agent") + + remote := "[" + r.RemoteAddr + if userAgent != "" { + remote += " UA(" + userAgent + ")" + } + remote += "]" + + return remote +} diff --git a/objalbum.go b/objalbum.go new file mode 100644 index 0000000..4954c52 --- /dev/null +++ b/objalbum.go @@ -0,0 +1,39 @@ +package main + +import ( + "encoding/json" +) + +// ObjectAlbum holds metadata about an album +type ObjectAlbum struct { + Discs []*ObjectDisc `json:"discs,omitempty"` //The discs in this album + Copyrights []string `json:"copyrights,omitempty"` //The copyrights that apply to this album + Label string `json:"label,omitempty"` //The record label or studio that released this album + Provider string `json:"provider,omitempty"` + URI string `json:"uri,omitempty"` //The URI that refers to this album object + Name string `json:"name,omitempty"` //The name of this album + Description string `json:"description,omitempty"` //The description of this album + Artworks []*ObjectArtwork `json:"artworks,omitempty"` //The artworks for this album + DateTime string `json:"datetime,omitempty"` //The release date of this album + Creators []*Object `json:"creators,omitempty"` //The creators of this album +} + +func (obj *ObjectAlbum) JSON() []byte { + objJSON, err := json.Marshal(obj) + if err != nil { + return nil + } + return objJSON +} + +func (obj *ObjectAlbum) IsEmpty() bool { + if len(obj.Discs) == 0 { + return true + } + for i := 0; i < len(obj.Discs); i++ { + if len(obj.Discs[i].Streams) > 0 { + return false + } + } + return true +} \ No newline at end of file diff --git a/objartwork.go b/objartwork.go new file mode 100644 index 0000000..1734c49 --- /dev/null +++ b/objartwork.go @@ -0,0 +1,26 @@ +package main + +import ( + "encoding/json" +) + +// ObjectArtwork holds metadata about an artwork +type ObjectArtwork struct { + Provider string `json:"provider,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + URL string `json:"url,omitempty"` + Type string `json:"type,omitempty"` //The file type, i.e. mp4 or jpg +} + +func (obj *ObjectArtwork) JSON() []byte { + objJSON, err := json.Marshal(obj) + if err != nil { + return nil + } + return objJSON +} + +func NewObjArtwork(provider, fileType, url string, width, height int) *ObjectArtwork { + return &ObjectArtwork{provider, width, height, url, fileType} +} \ No newline at end of file diff --git a/objcreator.go b/objcreator.go new file mode 100644 index 0000000..feaa2aa --- /dev/null +++ b/objcreator.go @@ -0,0 +1,35 @@ +package main + +import ( + "encoding/json" + "time" +) + +// ObjectCreator holds metadata about a creator +type ObjectCreator struct { + Genres []string `json:"genres,omitempty"` //The genres known to define this creator + Albums []*Object `json:"albums,omitempty"` //The albums from this creator + Provider string `json:"provider,omitempty"` + URI string `json:"uri,omitempty"` //The URI that refers to this creator object + Name string `json:"name,omitempty"` //The name of this creator + Description string `json:"description,omitempty"` //The description or biography of this creator + Artworks []*ObjectArtwork `json:"artworks,omitempty"` //The artworks for this creator + DateTime *time.Time `json:"datetime,omitempty"` //The debut date of this creator + TopStreams []*Object `json:"topStreams,omitempty"` //The top X streams from this creator + Appearances []*Object `json:"appearances,omitempty"` //The albums this creator appears on + Singles []*Object `json:"singles,omitempty"` //The single streams from this creator + Playlists []*Object `json:"playlists,omitempty"` //The playlists from this creator + Related []*Object `json:"related,omitempty"` //The creators related to this creator +} + +func (obj *ObjectCreator) JSON() []byte { + objJSON, err := json.Marshal(obj) + if err != nil { + return nil + } + return objJSON +} + +func (obj *ObjectCreator) IsEmpty() bool { + return len(obj.Albums) == 0 && len(obj.TopStreams) == 0 && len(obj.Appearances) == 0 && len(obj.Singles) == 0 && len(obj.Playlists) == 0 && len(obj.Related) == 0 +} \ No newline at end of file diff --git a/objdisc.go b/objdisc.go new file mode 100644 index 0000000..b0a5c71 --- /dev/null +++ b/objdisc.go @@ -0,0 +1,22 @@ +package main + +import ( + "encoding/json" +) + +// ObjectDisc holds a list of streams +type ObjectDisc struct { + Provider string `json:"provider,omitempty"` + Disc int `json:"disc,omitempty"` //The disc or part number of an album + Name string `json:"name,omitempty"` //The name of this disc + Artworks []*ObjectArtwork `json:"artworks,omitempty"` //The artworks for this disc + Streams []*Object `json:"streams,omitempty"` //The streams on this disc +} + +func (obj *ObjectDisc) JSON() []byte { + objJSON, err := json.Marshal(obj) + if err != nil { + return nil + } + return objJSON +} \ No newline at end of file diff --git a/objects.go b/objects.go new file mode 100644 index 0000000..181ea90 --- /dev/null +++ b/objects.go @@ -0,0 +1,356 @@ +package main + +import ( + "encoding/json" + "io/ioutil" + "os" + "strings" + "time" +) + +// Object holds a metadata object +type Object struct { + URI string `json:"uri,omitempty"` //The URI that matches this object + Type string `json:"type,omitempty"` //search, stream, creator, album + Provider string `json:"provider,omitempty"` //The service that provides this object + Expires *time.Time `json:"expires,omitempty"` //When this object should expire by + LastMod *time.Time `json:"lastMod,omitempty"` //When this object was last altered + Object *json.RawMessage `json:"object,omitempty"` //Holds either the raw object or a string containing the object's reference URI + Expanding bool `json:"expanding,omitempty"` //Whether or not this object is in the process of internal expansion + Expanded bool `json:"expanded,omitempty"` //Whether or not this object has been expanded internally +} + +// JSON returns this object as serialized JSON +func (obj *Object) JSON() ([]byte, error) { + return json.Marshal(obj) +} + +func (obj *Object) SearchResults() *ObjectSearchResults { + if obj.Object == nil { + return nil + } + switch obj.Type { + case "search": + ret := &ObjectSearchResults{} + if objJSON, err := obj.Object.MarshalJSON(); err == nil { + if err := json.Unmarshal(objJSON, &ret); err == nil { + return ret + } + } + } + return nil +} + +func (obj *Object) Creator() *ObjectCreator { + if obj.Object == nil { + return nil + } + switch obj.Type { + case "artist", "creator", "user", "channel", "chan", "streamer": + ret := &ObjectCreator{} + if objJSON, err := obj.Object.MarshalJSON(); err == nil { + if err := json.Unmarshal(objJSON, &ret); err == nil { + return ret + } + } + } + return nil +} + +func (obj *Object) Album() *ObjectAlbum { + if obj.Object == nil { + return nil + } + switch obj.Type { + case "album": + ret := &ObjectAlbum{} + if objJSON, err := obj.Object.MarshalJSON(); err == nil { + if err := json.Unmarshal(objJSON, &ret); err == nil { + return ret + } + } + } + return nil +} + +func (obj *Object) Stream() *ObjectStream { + if obj.Object == nil { + return nil + } + switch obj.Type { + case "track", "song", "video", "audio", "stream": + ret := &ObjectStream{} + if objJSON, err := obj.Object.MarshalJSON(); err == nil { + if err := json.Unmarshal(objJSON, &ret); err == nil { + return ret + } + } + } + return nil +} + +// Sync writes this object to the cache +func (obj *Object) Sync() { + if obj.URI == "" { + return + } + expiryTime := time.Now() + switch obj.Type { + case "search": //2 hours + objSearch := obj.SearchResults() + if objSearch != nil && objSearch.IsEmpty() { + return + } + expiryTime = expiryTime.Add(time.Hour * 2) + case "artist", "creator", "user", "channel", "chan", "streamer": //12 hours + objCreator := obj.Creator() + if objCreator != nil && objCreator.IsEmpty() { + return + } + expiryTime = expiryTime.Add(time.Hour * 12) + case "album", "track", "song", "video", "audio", "stream": //30 days + objAlbum := obj.Album() + if objAlbum != nil && objAlbum.IsEmpty() { + return + } + expiryTime = expiryTime.Add(time.Hour * (24 * 30)) + } + obj.LastMod = &expiryTime + obj.Expires = &expiryTime + + objData, err := obj.JSON() + if err != nil { + return + } + splitURI := strings.Split(obj.URI, ":") + pathURL := "cache/" + fileName := "" + for i := 0; i < len(splitURI); i++ { + if i == len(splitURI)-1 { + fileName = splitURI[i] + ".json" + break + } + pathURL += splitURI[i] + "/" + } + os.MkdirAll(pathURL, 0777) + pathURL += fileName + ioutil.WriteFile(pathURL, objData, 0777) +} + +// Expand fills in all top-level object arrays with completed objects +func (src *Object) Expand() { + if src.URI == "" { + return + } + //Check if object is being expanded right now + if src.Expanding { + //Sleep and try again + return + } + src.Expanding = true + src.Expanded = false + src.Sync() + Trace.Println("Expanding " + src.URI) + switch src.Type { + case "search": + if search := src.SearchResults(); search != nil { + syncSearch := func() { + searchJSON, err := json.Marshal(search) + if err == nil { + src.Object = &json.RawMessage{} + src.Object.UnmarshalJSON(searchJSON) + src.Sync() + } + } + for i := 0; i < len(search.Streams); i++ { + if search.Streams[i].URI == "" { + continue + } + search.Streams[i] = GetObject(search.Streams[i].URI) + syncSearch() + } + for i := 0; i < len(search.Creators); i++ { + if search.Creators[i].URI == "" { + continue + } + search.Creators[i] = GetObject(search.Creators[i].URI) + syncSearch() + } + for i := 0; i < len(search.Albums); i++ { + if search.Albums[i].URI == "" { + continue + } + search.Albums[i] = GetObject(search.Albums[i].URI) + syncSearch() + } + } + case "artist", "creator", "user", "channel", "chan", "streamer": + if creator := src.Creator(); creator != nil { + syncCreator := func() { + creatorJSON, err := json.Marshal(creator) + if err == nil { + src.Object = &json.RawMessage{} + src.Object.UnmarshalJSON(creatorJSON) + src.Sync() + } + } + for i := 0; i < len(creator.TopStreams); i++ { + if creator.TopStreams[i].URI == "" { + continue + } + creator.TopStreams[i] = GetObject(creator.TopStreams[i].URI) + syncCreator() + } + for i := 0; i < len(creator.Albums); i++ { + if creator.Albums[i].URI == "" { + continue + } + creator.Albums[i] = GetObject(creator.Albums[i].URI) + syncCreator() + } + for i := 0; i < len(creator.Appearances); i++ { + if creator.Appearances[i].URI == "" { + continue + } + creator.Appearances[i] = GetObject(creator.Appearances[i].URI) + syncCreator() + } + for i := 0; i < len(creator.Singles); i++ { + if creator.Singles[i].URI == "" { + continue + } + creator.Singles[i] = GetObject(creator.Singles[i].URI) + syncCreator() + } + for i := 0; i < len(creator.Related); i++ { + if creator.Related[i].URI == "" { + continue + } + creator.Related[i] = GetObject(creator.Related[i].URI) + syncCreator() + } + } + case "album": + if album := src.Album(); album != nil { + syncAlbum := func() { + albumJSON, err := json.Marshal(album) + if err == nil { + src.Object = &json.RawMessage{} + src.Object.UnmarshalJSON(albumJSON) + src.Sync() + } + } + for i := 0; i < len(album.Creators); i++ { + if album.Creators[i].URI == "" { + continue + } + album.Creators[i] = GetObject(album.Creators[i].URI) + syncAlbum() + } + for i := 0; i < len(album.Discs); i++ { + for j := 0; j < len(album.Discs[i].Streams); j++ { + if album.Discs[i].Streams[j].URI == "" { + continue + } + album.Discs[i].Streams[j] = GetObject(album.Discs[i].Streams[j].URI) + syncAlbum() + } + } + } + case "track", "song", "video", "audio", "stream": + if stream := src.Stream(); stream != nil { + syncStream := func() { + streamJSON, err := json.Marshal(stream) + if err == nil { + src.Object = &json.RawMessage{} + src.Object.UnmarshalJSON(streamJSON) + src.Sync() + } + } + stream.Album = GetObject(stream.Album.URI) + syncStream() + for i := 0; i < len(stream.Creators); i++ { + if stream.Creators[i].URI == "" { + continue + } + stream.Creators[i] = GetObject(stream.Creators[i].URI) + syncStream() + } + } + } + if src.Object != nil { + src.Expanding = false + src.Expanded = true + Trace.Println("Finished expanding " + src.URI) + } else { + src.Expanding = false + Trace.Println("Failed to expand " + src.URI) + } + src.Sync() +} + +// GetObjectCached returns a new object from the cache that links to a given URI +func GetObjectCached(uri string) (obj *Object) { + Trace.Println("Retrieving " + uri + " from the cache") + + splitURI := strings.Split(uri, ":") + pathURL := "cache/" + for i := 0; i < len(splitURI); i++ { + if i == len(splitURI)-1 { + break + } + pathURL += splitURI[i] + "/" + } + pathURL += splitURI[len(splitURI)-1] + ".json" + + //Check if the object exists + info, err := os.Stat(pathURL) + if os.IsNotExist(err) { + //Trace.Println("Object " + uri + " does not exist in cache") + return nil + } + if info.IsDir() { + Warning.Println("Object " + uri + " points to a directory") + return nil + } + + //Try reading the object from disk + objData, err := ioutil.ReadFile(pathURL) + if err != nil { + Error.Println("Object " + uri + " failed to read from cache, garbage collecting it instead") + os.Remove(pathURL) + return nil + } + + //Map the object into memory, or invalidate it to be resynced if that fails + obj = &Object{Object: &json.RawMessage{}} + err = json.Unmarshal(objData, obj) + if err != nil { + Error.Println("Object " + uri + " failed to map into memory, garbage collecting it instead") + os.Remove(pathURL) + return nil + } + + //Check if object expired and was missed during cleanup + if obj.Expires != nil && time.Now().After(*obj.Expires) { + Error.Println("Object " + uri + " expired, garbage collecting") + os.Remove(pathURL) + return nil + } + + return obj +} + +// NewObjError returns an error object +func NewObjError(msg string) (obj *Object) { + obj = &Object{ + Type: "error", + Provider: "libremedia", + Object: &json.RawMessage{}, + } + errJSON, err := json.Marshal(&exporterr{Error: msg}) + if err == nil { + obj.Object.UnmarshalJSON(errJSON) + } + return obj +} diff --git a/objformat.go b/objformat.go new file mode 100644 index 0000000..782f77a --- /dev/null +++ b/objformat.go @@ -0,0 +1,32 @@ +package main + +import ( + "encoding/json" + "strconv" +) + +// ObjectFormat holds the URL to stream this file and its codec and format information +type ObjectFormat struct { + Provider string `json:"provider,omitempty"` + ID int `json:"id,omitempty"` //The ID of this format's quality; lower is better + Name string `json:"name,omitempty"` //The title or name of this format type + URL string `json:"url,omitempty"` //Ex (qualityId would be 2 for OGG 320Kbps if Spotify): /stream/{uri}?quality={qualityId} + Format string `json:"format,omitempty"` //Ex: ogg, mp4 + Codec string `json:"codec,omitempty"` //Ex: vorbis, h264 + BitRate int32 `json:"bitrate,omitempty"` //Ex: 320000, 5500 + BitDepth int `json:"bitdepth,omitempty"` //Ex: 8, 16, 24, 32 + SampleRate int32 `json:"samplerate,omitempty"` //Ex: 96000 or 44100, 30 or 60 + File interface{} `json:"-"` //A place for the live session to store a temporary file +} + +func (obj *ObjectFormat) JSON() []byte { + objJSON, err := json.Marshal(obj) + if err != nil { + return nil + } + return objJSON +} + +func (obj *ObjectFormat) GenerateURL(uri string) { + obj.URL = service.BaseURL + "v1/stream/" + uri + "?format=" + strconv.Itoa(obj.ID) +} \ No newline at end of file diff --git a/objsearch.go b/objsearch.go new file mode 100644 index 0000000..eb02feb --- /dev/null +++ b/objsearch.go @@ -0,0 +1,26 @@ +package main + +import ( + "encoding/json" +) + +// ObjectSearchResults holds the results for a given search query +type ObjectSearchResults struct { + Query string `json:"query,omitempty"` //The query that generated these results + Streams []*Object `json:"streams,omitempty"` //The stream results for this query + Creators []*Object `json:"creators,omitempty"` //The creator results for this query + Albums []*Object `json:"albums,omitempty"` //The album results for this query + Provider string `json:"provider,omitempty"` +} + +func (obj *ObjectSearchResults) JSON() []byte { + objJSON, err := json.Marshal(obj) + if err != nil { + return nil + } + return objJSON +} + +func (obj *ObjectSearchResults) IsEmpty() bool { + return len(obj.Streams) == 0 && len(obj.Creators) == 0 && len(obj.Albums) == 0 +} \ No newline at end of file diff --git a/objstream.go b/objstream.go new file mode 100644 index 0000000..6f7ab1e --- /dev/null +++ b/objstream.go @@ -0,0 +1,100 @@ +package main + +import ( + "encoding/json" + "strings" + + "github.com/rhnvrm/lyric-api-go" +) + +// ObjectStream holds metadata about a stream and the available formats to stream +type ObjectStream struct { + Track int `json:"track,omitempty"` //The track number of the disc/album that holds this stream + Name string `json:"name,omitempty"` //The name of this file + Visual bool `json:"visual,omitempty"` //Set to true if designed to be streamed as video + Duration int64 `json:"duration,omitempty"` //The duration of this file in seconds + Formats []*ObjectFormat `json:"formats,omitempty"` //The stream formats available for this file + Language string `json:"language,omitempty"` //Ex: en, english, es, spanish, etc - something unified for purpose + Transcript *ObjectTranscript `json:"transcript,omitempty"` //Ex: lyrics for a song, closed captions or transcript of a video/recording, etc + Artworks []*ObjectArtwork `json:"artworks,omitempty"` //The artworks for this file + Creators []*Object `json:"creators,omitempty"` //The creators of this file + Album *Object `json:"album,omitempty"` //The album that holds this file + DateTime string `json:"datetime,omitempty"` //The release date of this file + Provider string `json:"provider,omitempty"` + URI string `json:"uri,omitempty"` //The URI that refers to this stream object + ID string `json:"id,omitempty"` //The ID that refers to this stream object + Extdata map[string]string `json:"extdata,omitempty"` //To store additional service-specific and file-defined metadata +} + +func (obj *ObjectStream) JSON() []byte { + objJSON, err := json.Marshal(obj) + if err != nil { + return nil + } + return objJSON +} + +func (obj *ObjectStream) FileName() string { + creatorName := obj.Creators[0].Creator().Name + album := obj.Album.Album() + albumName := album.Name + albumDate := album.DateTime + trackName := obj.Name + + fileName := creatorName + " - " + albumName + if albumDate != "" { + fileName += " " + albumDate + } + fileName += " - " + trackName + "." + obj.Formats[0].Format + return fileName +} + +func (obj *ObjectStream) GetFormat(format int) *ObjectFormat { + for i := 0; i < len(obj.Formats); i++ { + if obj.Formats[i].ID == format { + return obj.Formats[i] + } + } + return nil +} + +func (obj *ObjectStream) Transcribe() { + if obj.Transcript != nil && len(obj.Transcript.Lines) > 0 { + return //You should expire the object if you want to resync it + } + + //Try the source of the object first + //TODO: Check all providers when providers can be an array + if handler, exists := handlers[obj.Provider]; exists { + if err := handler.Transcribe(obj); err == nil { + return + } + } + + //Make sure we at least know the creator and stream names first + if len(obj.Creators) > 0 && obj.Name != "" { + l := lyrics.New() + + //We want the first creator that matches a result + for i := 0; i < len(obj.Creators); i++ { + creator := obj.Creators[i].Creator() + if creator != nil { + transcript, err := l.Search(creator.Name, obj.Name) + if err == nil { + obj.Transcript = &ObjectTranscript{ + Provider: "libremedia", + ProviderLyricsID: obj.ID, + ProviderTrackID: obj.ID, + Lines: make([]*ObjectTranscriptLine, 0), + } + lines := strings.Split(transcript, "\n") + for j := 0; j < len(lines); j++ { + line := lines[j] + obj.Transcript.Lines = append(obj.Transcript.Lines, &ObjectTranscriptLine{Text: line}) + } + return + } + } + } + } +} \ No newline at end of file diff --git a/objtranscript.go b/objtranscript.go new file mode 100644 index 0000000..8a933a9 --- /dev/null +++ b/objtranscript.go @@ -0,0 +1,15 @@ +package main + +type ObjectTranscript struct { + RightToLeft bool `json:"rightToLeft,omitempty"` + Provider string `json:"provider,omitempty"` + ProviderLyricsID string `json:"providerLyricsId,omitempty"` + ProviderTrackID string `json:"providerTrackId,omitempty"` //SyncLyricsURI + TimeSynced bool `json:"timeSynced,omitempty"` + Lines []*ObjectTranscriptLine `json:"lines,omitempty"` +} + +type ObjectTranscriptLine struct { + StartTimeMs int `json:"startTimeMs,omitempty"` + Text string `json:"text,omitempty"` +} \ No newline at end of file diff --git a/plugins.go b/plugins.go new file mode 100644 index 0000000..07ab822 --- /dev/null +++ b/plugins.go @@ -0,0 +1,127 @@ +package main + +import ( + "fmt" + //"os/exec" + "strings" +) + +var ( + channels map[string][]*Packet +) + +type Plugin struct { + Active bool `json:"active"` //Whether or not this plugin should be loaded + Path string `json:"path"` //Path to plugin + Method string `json:"method"` //Method for loading the plugin (TCP,BIN) + + closed bool `json:"-"` //If Close() was called + transport interface{} `json:"-"` //Loaded transport handler (TCP socket, OS process, etc) +} + +func (p *Plugin) Load() error { + switch strings.ToLower(p.Method) { + case "bin": + //TODO: Execute p.Path as OS process + //TODO: Store OS process in p.transport + p.closed = false + return nil + case "tcp": + //TODO: Connect to p.Path as TCP address + //TODO: Store TCP socket in p.transport + p.closed = false + return nil + } + return fmt.Errorf("plugin: Invalid method %s", p.Method) +} + +func (p *Plugin) Close() error { + switch strings.ToLower(p.Method) { + case "bin": + //TODO: Interpret as OS process and Close() + case "tcp": + //TODO: Interpret as TCP socket and Close() + } + p.transport = nil + p.closed = true + return nil +} + +//Receive will read and store all incoming packets from the plugin's transport until either are closed +func (p *Plugin) Receive() error { + if p.closed { + return fmt.Errorf("plugin: Cannot receive on closed plugin") + } + for { + if p.closed { + break + } + switch strings.ToLower(p.Method) { + case "bin": + //TODO: Interpret as OS process and Read() + //TODO: Call p.Store(b) when CRLF is reached + case "tcp": + //TODO: Interpret as TCP socket and Read() + //TODO: Call p.Store(b) when CRLF is reached + } + } + return nil +} + +//Send will write a request packet to the plugin's transport +func (p *Plugin) Send(op string, b []byte) error { + if p.closed { + return fmt.Errorf("plugin: Cannot send on closed plugin") + } + //packet := NewPacket(op, b) + switch strings.ToLower(p.Method) { + case "bin": + //TODO: Interpret as OS process and Write(packet) + case "tcp": + //TODO: Interpret as TCP socket and Write(packet) + } + return nil +} + +//Store will parse the given packet and store it in the appropriate channel +func (p *Plugin) Store(b []byte) error { + var packet *Packet + //TODO: Decode b into *Packet + if _, exists := channels[packet.Id]; !exists { + channels[packet.Id] = make([]*Packet, 0) + } + channels[packet.Id] = append(channels[packet.Id], packet) + return nil +} + +//IsPacketAvailable checks if a packet is available on the specified channel +func (p *Plugin) IsPacketAvailable(id string) bool { + if channel, exists := channels[id]; exists { + if len(channel) > 0 { + return true + } + } + return false +} + +//ReadPacket reads the next packet from the given channel +func (p *Plugin) ReadPacket(id string) *Packet { + if channel, exists := channels[id]; exists { + if len(channel) > 0 { + packet := channel[0] + //TODO: Remove index 0 from slice stored in channels[id] map + return packet + } + } + return nil +} + +type Packet struct { + Id string `json:"id"` //The channel for this packet, should be unique per request as response will match + Op string `json:"op"` //The operation to call + Data []byte `json:"data"` //The data for this operation +} + +func NewPacket(op string, b []byte) []byte { + return nil +} diff --git a/service.go b/service.go new file mode 100644 index 0000000..3cc8234 --- /dev/null +++ b/service.go @@ -0,0 +1,123 @@ +package main + +import ( + "fmt" + "net/http" + "time" +) + +var ( + handlers = map[string]Handler{ + "tidal": &TidalClient{}, + "spotify": &SpotifyClient{}, + } + providers = make([]string, 0) +) + +type Handler interface { + Provider() string //Used for service identification + SetService(*Service) //Provides the handler access to the libremedia service + Authenticate(*HandlerConfig) (Handler, error) //Attempts to authenticate with the given configuration + Creator(id string) (*ObjectCreator, error) //Returns the matching creator object for metadata + Album(id string) (*ObjectAlbum, error) //Returns the matching album object for metadata + Stream(id string) (*ObjectStream, error) //Returns the matching stream object for metadata + StreamFormat(w http.ResponseWriter, r *http.Request, stream *ObjectStream, format int) error + FormatList() []*ObjectFormat //Returns all the possible formats as templates ordered from best to worst + Search(query string) (*ObjectSearchResults, error) //Returns all the available search results that match the query + Transcribe(obj *ObjectStream) error //Fills in the stream's transcript with lyrics, closed captioning, subtitles, etc + ReplaceURI(text string) string //Replaces all instances of a URI with a libremedia-acceptable URI, for dynamic hyperlinking +} + +type HandlerConfig struct { + Active bool `json:"active"` + Username string `json:"username"` + Password string `json:"password"` + DeviceName string `json:"deviceName"` + BlobPath string `json:"blobPath"` +} + +type Service struct { + AccessKeys []string `json:"accessKeys"` + BaseURL string `json:"baseURL"` + Handlers map[string]*HandlerConfig `json:"handlers"` + HostAddr string `json:"httpAddr"` + + Grants map[string]*ServiceUser `json:"-"` +} + +func (s *Service) Login() error { + if s.BaseURL[len(s.BaseURL)-1] != '/' { + s.BaseURL += "/" + } + for provider, config := range s.Handlers { + if handler, exists := handlers[provider]; exists { + if config.Active { + newHandler, err := handler.Authenticate(config) + if err != nil { + Error.Println("Failed to authenticate " + provider + ": ", err) + return err + } + newHandler.SetService(s) + handlers[provider] = newHandler + providers = append(providers, provider) + } else { + Trace.Println("Skipping authenticating " + provider) + delete(handlers, provider) + } + } + } + return nil +} + +func (s *Service) Auth(accessKey string) (*ServiceUser, error) { + allow := false + for i := 0; i < len(s.AccessKeys); i++ { + if s.AccessKeys[i] == accessKey { + allow = true + break + } + } + if !allow { + return nil, fmt.Errorf("invalid accessKey") + } + return nil, nil +} + +func (s *Service) Stream(w http.ResponseWriter, r *http.Request, stream *ObjectStream, format int) error { + if stream == nil { + return fmt.Errorf("stream is nil") + } + if stream.Provider == "" { + return fmt.Errorf("provider not specified") + } +/* if stream.Formats == nil || len(stream.Formats) <= format { + objStream := GetObject(stream.URI, false) + if objStream != nil { + stream = objStream.Stream() + if stream.Formats == nil || len(stream.Formats) <= format { + return fmt.Errorf("format not available to stream") + } + } else { + return fmt.Errorf("stream not available right now") + } + } +*/ if handler, exists := handlers[stream.Provider]; exists { + return handler.StreamFormat(w, r, stream, format) + } + return fmt.Errorf("no handler for provider " + stream.Provider) +} + +func (s *Service) Download(w http.ResponseWriter, r *http.Request, stream *ObjectStream, format int) error { + if stream.Provider == "" { + return fmt.Errorf("provider not specified") + } + if handler, exists := handlers[stream.Provider]; exists { + w.Header().Set("Content-Disposition", "attachment; filename="+stream.FileName()) + return handler.StreamFormat(w, r, stream, format) + } + return fmt.Errorf("no handler for provider " + stream.Provider) +} + +type ServiceUser struct { + Expires time.Time +} diff --git a/spotify.go b/spotify.go new file mode 100644 index 0000000..9a56d44 --- /dev/null +++ b/spotify.go @@ -0,0 +1,700 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "regexp" + "sync" + "time" + + "github.com/eolso/librespot-golang/Spotify" + "github.com/eolso/librespot-golang/librespot" + "github.com/eolso/librespot-golang/librespot/core" + "github.com/eolso/librespot-golang/librespot/mercury" + "github.com/eolso/librespot-golang/librespot/utils" +) + +/* + - GetPlaylist(playlistID string) (*ObjectPlaylist, error) + - GetUserPlaylist(userID, playlistID string) (*ObjectPlaylist, error) + - GetSuggest(query string) ([]string, error) +*/ + +var spoturire = regexp.MustCompile(`(.*?)<\/a>`) + +// SpotifyClient holds a Spotify client +type SpotifyClient struct { + sync.Mutex + + Session *core.Session + Service *Service +} + +func (s *SpotifyClient) Provider() string { + return "spotify" +} + +func (s *SpotifyClient) SetService(service *Service) { + s.Service = service +} + +func (s *SpotifyClient) Authenticate(cfg *HandlerConfig) (handler Handler, err error) { + if cfg.Username == "" || cfg.Password == "" || cfg.BlobPath == "" { + return nil, fmt.Errorf("spotify: must provide username, password, and path to file for storing auth blob") + } + username := cfg.Username + password := cfg.Password + deviceName := cfg.DeviceName + blobPath := cfg.BlobPath + + if _, err := os.Stat(blobPath); !os.IsNotExist(err) { + blobBytes, err := ioutil.ReadFile(blobPath) + if err != nil { + return nil, err + } + session, err := librespot.LoginSaved(username, blobBytes, deviceName) + if err != nil { + return nil, err + } + s.Session = session + return s, nil + } + + session, err := librespot.Login(username, password, deviceName) + if err != nil { + return nil, err + } + s.Session = session + + err = ioutil.WriteFile(blobPath, session.ReusableAuthBlob(), 0600) + if err != nil { + return nil, err + } + + return s, nil +} + +// NewSpotify authenticates to Spotify and returns a Spotify session +func NewSpotify(username, password, deviceName, blobPath string) (*SpotifyClient, error) { + if username == "" || password == "" || blobPath == "" { + return nil, fmt.Errorf("must provide username, password, and filepath to auth blob") + } + + //Don't return unless blob authentication is successful, we'll reauth if it fails and only return an error then + if _, err := os.Stat(blobPath); !os.IsNotExist(err) { //File exists + blobBytes, err := ioutil.ReadFile(blobPath) + if err == nil { + session, err := librespot.LoginSaved(username, blobBytes, deviceName) + if err == nil { + return &SpotifyClient{Session: session}, nil + } + } + } + + session, err := librespot.Login(username, password, deviceName) + if err != nil { + return nil, err + } + + err = ioutil.WriteFile(blobPath, session.ReusableAuthBlob(), 0600) + if err != nil { + return nil, err + } + + return &SpotifyClient{Session: session}, nil +} + +func (s *SpotifyClient) mercuryGet(url string) []byte { + m := s.Session.Mercury() + done := make(chan []byte) + go m.Request(mercury.Request{ + Method: "GET", + Uri: url, + Payload: [][]byte{}, + }, func(res mercury.Response) { + done <- res.CombinePayload() + }) + + result := <-done + return result +} + +func (s *SpotifyClient) mercuryGetJson(url string, result interface{}) (err error) { + data := s.mercuryGet(url) + //Trace.Printf("Spotify Mercury JSON: %s\n", data) + err = json.Unmarshal(data, result) + return +} + +// Creator gets an artist object from Spotify +func (s *SpotifyClient) Creator(creatorID string) (creator *ObjectCreator, err error) { + s.Lock() + defer s.Unlock() + + spotCreator, err := s.Session.Mercury().GetArtist(utils.Base62ToHex(creatorID)) + if err != nil { + return nil, err + } + biography := "" + if len(spotCreator.Biography) > 0 { + spotBios := spotCreator.Biography + for i := 0; i < len(spotBios); i++ { + if spotBios[i] != nil && spotBios[i].Text != nil { + biography += *spotBios[i].Text + if i == len(spotBios)-1 { + biography += "\n\n" + } + } + } + if biography != "" { + biography = s.ReplaceURI(biography) + } + } + topTracks := make([]*Object, 0) + for i := 0; i < len(spotCreator.TopTrack); i++ { + for j := 0; j < len(spotCreator.TopTrack[i].Track); j++ { + topTrack := spotCreator.TopTrack[i].Track[j] + objStream := &ObjectStream{URI: "spotify:track:" + gid2Id(topTrack.Gid)} + obj := &Object{ + URI: "spotify:track:" + gid2Id(topTrack.Gid), + Type: "stream", + Provider: "spotify", + Object: &json.RawMessage{}, + } + obj.Object.UnmarshalJSON(objStream.JSON()) + topTracks = append(topTracks, obj) + } + } + albums := make([]*Object, 0) + for i := 0; i < len(spotCreator.AlbumGroup); i++ { + for j := 0; j < len(spotCreator.AlbumGroup[i].Album); j++ { + spotAlbum := spotCreator.AlbumGroup[i].Album[j] + objAlbum := &ObjectAlbum{URI: "spotify:album:" + gid2Id(spotAlbum.Gid)} + obj := &Object{ + URI: "spotify:album:" + gid2Id(spotAlbum.Gid), + Type: "album", + Provider: "spotify", + Object: &json.RawMessage{}, + } + obj.Object.UnmarshalJSON(objAlbum.JSON()) + albums = append(albums, obj) + } + } + appearances := make([]*Object, 0) + for i := 0; i < len(spotCreator.AppearsOnGroup); i++ { + for j := 0; j < len(spotCreator.AppearsOnGroup[i].Album); j++ { + spotAlbum := spotCreator.AppearsOnGroup[i].Album[j] + objAlbum := &ObjectAlbum{URI: "spotify:album:" + gid2Id(spotAlbum.Gid)} + obj := &Object{ + URI: "spotify:album:" + gid2Id(spotAlbum.Gid), + Type: "album", + Provider: "spotify", + Object: &json.RawMessage{}, + } + obj.Object.UnmarshalJSON(objAlbum.JSON()) + appearances = append(appearances, obj) + } + } + singles := make([]*Object, 0) + for i := 0; i < len(spotCreator.SingleGroup); i++ { + for j := 0; j < len(spotCreator.SingleGroup[i].Album); j++ { + spotAlbum := spotCreator.SingleGroup[i].Album[j] + objAlbum := &ObjectAlbum{URI: "spotify:album:" + gid2Id(spotAlbum.Gid)} + obj := &Object{ + URI: "spotify:album:" + gid2Id(spotAlbum.Gid), + Type: "album", + Provider: "spotify", + Object: &json.RawMessage{}, + } + obj.Object.UnmarshalJSON(objAlbum.JSON()) + singles = append(singles, obj) + } + } + related := make([]*Object, len(spotCreator.Related)) + for i := 0; i < len(related); i++ { + relatedCreator := spotCreator.Related[i] + objCreator := &ObjectCreator{ + URI: "spotify:artist:" + gid2Id(relatedCreator.Gid), + Name: *relatedCreator.Name, + } + related[i] = &Object{ + URI: "spotify:artist:" + gid2Id(relatedCreator.Gid), + Type: "creator", + Provider: "spotify", + Object: &json.RawMessage{}, + } + related[i].Object.UnmarshalJSON(objCreator.JSON()) + } + creator = &ObjectCreator{ + URI: "spotify:artist:" + creatorID, + Name: *spotCreator.Name, + Description: biography, + Genres: spotCreator.Genre, + TopStreams: topTracks, + Albums: albums, + Appearances: appearances, + Singles: singles, + Related: related, + } + return +} + +// Album gets an album object from Spotify +func (s *SpotifyClient) Album(albumID string) (album *ObjectAlbum, err error) { + s.Lock() + defer s.Unlock() + + spotAlbum, err := s.Session.Mercury().GetAlbum(utils.Base62ToHex(albumID)) + if err != nil { + return nil, err + } + creators := make([]*Object, len(spotAlbum.Artist)) + for i := 0; i < len(creators); i++ { + objCreator := &ObjectCreator{ + URI: "spotify:artist:" + gid2Id(spotAlbum.Artist[i].Gid), + Name: *spotAlbum.Artist[i].Name, + } + creators[i] = &Object{ + URI: "spotify:artist:" + gid2Id(spotAlbum.Artist[i].Gid), + Type: "creator", + Provider: "spotify", + Object: &json.RawMessage{}, + } + creators[i].Object.UnmarshalJSON(objCreator.JSON()) + } + discs := make([]*ObjectDisc, len(spotAlbum.Disc)) + for i := 0; i < len(discs); i++ { + discStreams := make([]*Object, len(spotAlbum.Disc[i].Track)) + for j := 0; j < len(spotAlbum.Disc[i].Track); j++ { + spotTrack := spotAlbum.Disc[i].Track[j] + objStream := &ObjectStream{ + URI: "spotify:track:" + gid2Id(spotTrack.Gid), + ID: gid2Id(spotTrack.Gid), + } + discStreams[j] = &Object{ + URI: "spotify:track:" + gid2Id(spotTrack.Gid), + Type: "stream", + Provider: "spotify", + Object: &json.RawMessage{}, + } + discStreams[j].Object.UnmarshalJSON(objStream.JSON()) + } + discs[i] = &ObjectDisc{ + Streams: discStreams, + } + if spotAlbum.Disc[i].Number != nil { + discs[i].Disc = int(*spotAlbum.Disc[i].Number) + } + if spotAlbum.Disc[i].Name != nil { + discs[i].Name = *spotAlbum.Disc[i].Name + } + } + copyrights := make([]string, len(spotAlbum.Copyright)) + for i := 0; i < len(copyrights); i++ { + copyrights[i] = *spotAlbum.Copyright[i].Text + } + album = &ObjectAlbum{ + URI: "spotify:album:" + albumID, + Name: *spotAlbum.Name, + Creators: creators, + Discs: discs, + Copyrights: copyrights, + } + if spotAlbum.Label != nil { + album.Label = *spotAlbum.Label + } + if spotAlbum.Date != nil { + date := *spotAlbum.Date + dateTime := "" + if date.Year != nil && date.Month != nil { + dateTime = fmt.Sprintf("%d-%d", *date.Year, *date.Month) + if date.Day != nil { + dateTime += fmt.Sprintf("-%d", *date.Day) + } + } + if date.Hour != nil && date.Minute != nil { + if dateTime != "" { + dateTime += " " + } + dateTime += fmt.Sprintf("%d:%d", *date.Hour, *date.Minute) + } + album.DateTime = dateTime + } + return +} + +// Stream gets a stream object from Spotify +func (s *SpotifyClient) Stream(trackID string) (stream *ObjectStream, err error) { + s.Lock() + defer s.Unlock() + + spotTrack, err := s.Session.Mercury().GetTrack(utils.Base62ToHex(trackID)) + if err != nil { + return nil, err + } + creators := make([]*Object, len(spotTrack.Artist)) + for i := 0; i < len(creators); i++ { + creator := spotTrack.Artist[i] + objCreator := &ObjectCreator{ + Name: *creator.Name, + URI: "spotify:artist:" + gid2Id(creator.Gid), + } + creators[i] = &Object{ + URI: "spotify:artist:" + gid2Id(creator.Gid), + Type: "creator", + Provider: "spotify", + Object: &json.RawMessage{}, + } + creators[i].Object.UnmarshalJSON(objCreator.JSON()) + } + formats := make([]*ObjectFormat, 0) + formatList := s.FormatList() + for i := 0; i < len(formatList); i++ { + for j := 0; j < len(spotTrack.File); j++ { + switch i { + case 0: + if *spotTrack.File[j].Format == Spotify.AudioFile_OGG_VORBIS_320 { + formats = append(formats, formatList[i]) + formats[len(formats)-1].File = spotTrack.File[j] + break + } + case 1: + if *spotTrack.File[j].Format == Spotify.AudioFile_OGG_VORBIS_320 { + formats = append(formats, formatList[i]) + formats[len(formats)-1].File = spotTrack.File[j] + break + } + case 2: + if *spotTrack.File[j].Format == Spotify.AudioFile_OGG_VORBIS_320 { + formats = append(formats, formatList[i]) + formats[len(formats)-1].File = spotTrack.File[j] + break + } + case 3: + if *spotTrack.File[j].Format == Spotify.AudioFile_OGG_VORBIS_320 { + formats = append(formats, formatList[i]) + formats[len(formats)-1].File = spotTrack.File[j] + break + } + case 4: + if *spotTrack.File[j].Format == Spotify.AudioFile_OGG_VORBIS_320 { + formats = append(formats, formatList[i]) + formats[len(formats)-1].File = spotTrack.File[j] + break + } + case 5: + if *spotTrack.File[j].Format == Spotify.AudioFile_OGG_VORBIS_320 { + formats = append(formats, formatList[i]) + formats[len(formats)-1].File = spotTrack.File[j] + break + } + case 6: + if *spotTrack.File[j].Format == Spotify.AudioFile_OGG_VORBIS_320 { + formats = append(formats, formatList[i]) + formats[len(formats)-1].File = spotTrack.File[j] + break + } + } + } + } + stream = &ObjectStream{ + Provider: s.Provider(), + URI: "spotify:track:" + trackID, + ID: trackID, + Name: *spotTrack.Name, + Creators: creators, + Duration: int64(*spotTrack.Duration) / 1000, + Formats: formats, + } + if spotTrack.Album != nil { + album := &Object{ + URI: "spotify:album:" + gid2Id(spotTrack.Album.Gid), + Type: "album", + Provider: "spotify", + Object: &json.RawMessage{}, + } + stream.Album = album + albumObj := &ObjectAlbum{ + Name: *spotTrack.Album.Name, + URI: "spotify:album:" + gid2Id(spotTrack.Album.Gid), + } + stream.Album.Object.UnmarshalJSON(albumObj.JSON()) + } + return +} + +// Format gets a format object from a Spotify stream object +func (s *SpotifyClient) StreamFormat(w http.ResponseWriter, r *http.Request, stream *ObjectStream, format int) (err error) { + objFormat := stream.GetFormat(format) + if objFormat == nil { + return fmt.Errorf("spotify: unknown format %d for stream %s", format, stream.ID) + } + file, ok := objFormat.File.(*Spotify.AudioFile) + if !ok || file == nil { + stream, err = s.Stream(stream.ID) + if err != nil { + return fmt.Errorf("spotify: failed to get stream %s: %v", stream.ID, err) + } + objFormat = stream.GetFormat(format) + if objFormat == nil { + return fmt.Errorf("spotify: unknown format %d for stream %s after resyncing", format, stream.ID) + } + file = objFormat.File.(*Spotify.AudioFile) + if file == nil { + return fmt.Errorf("spotify: unknown file for format %d from stream %s after resyncing", format, stream.ID) + } + } + streamer, err := s.Session.Player().LoadTrackWithIdAndFormat(file.FileId, file.GetFormat(), id2Gid(stream.ID)) + if err != nil { + return fmt.Errorf("spotify: failed to load track for stream %s: %v", stream.ID, err) + } + w.Header().Set("Content-Type", "audio/ogg") + http.ServeContent(w, r, stream.ID, time.Time{}, streamer) + return nil +} + +// FormatList returns all the possible formats as templates ordered from best to worst +func (s *SpotifyClient) FormatList() (formats []*ObjectFormat) { + formats = []*ObjectFormat{ + &ObjectFormat{ + ID: 0, + Name: "Very High OGG", + Format: "ogg", + Codec: "vorbis", + BitRate: 320000, + BitDepth: 16, + SampleRate: 44100, + }, + &ObjectFormat{ + ID: 1, + Name: "Very High MP3", + Format: "mp3", + Codec: "mp3", + BitRate: 320000, + BitDepth: 16, + SampleRate: 44100, + }, + &ObjectFormat{ + ID: 2, + Name: "High MP3", + Format: "mp3", + Codec: "mp3", + BitRate: 256000, + BitDepth: 16, + SampleRate: 44100, + }, + &ObjectFormat{ + ID: 3, + Name: "Normal OGG", + Format: "ogg", + Codec: "vorbis", + BitRate: 160000, + BitDepth: 16, + SampleRate: 44100, + }, + &ObjectFormat{ + ID: 4, + Name: "Normal MP3", + Format: "mp3", + Codec: "mp3", + BitRate: 160000, + BitDepth: 16, + SampleRate: 44100, + }, + &ObjectFormat{ + ID: 5, + Name: "Low OGG", + Format: "ogg", + Codec: "vorbis", + BitRate: 96000, + BitDepth: 16, + SampleRate: 44100, + }, + &ObjectFormat{ + ID: 6, + Name: "Low MP3", + Format: "mp3", + Codec: "mp3", + BitRate: 96000, + BitDepth: 16, + SampleRate: 44100, + }, + } + return +} + +// Search returns the results matching a given query +func (s *SpotifyClient) Search(query string) (results *ObjectSearchResults, err error) { + s.Lock() + defer s.Unlock() + + searchResponse, err := s.Session.Mercury().Search(query, 10, s.Session.Country(), s.Session.Username()) + if err != nil { + return nil, err + } + + results = &ObjectSearchResults{} + results.Query = query + if searchResponse.Results.Artists.Total > 0 { + artists := searchResponse.Results.Artists.Hits + for i := 0; i < len(artists); i++ { + creator := &ObjectCreator{ + Name: artists[i].Name, + URI: artists[i].Uri, + } + obj := &Object{URI: creator.URI, Type: "creator", Provider: "spotify", Object: &json.RawMessage{}} + obj.Object.UnmarshalJSON(creator.JSON()) + + results.Creators = append(results.Creators, obj) + } + } + if searchResponse.Results.Albums.Total > 0 { + albums := searchResponse.Results.Albums.Hits + for i := 0; i < len(albums); i++ { + album := &ObjectAlbum{ + Name: albums[i].Name, + URI: albums[i].Uri, + } + obj := &Object{URI: album.URI, Type: "album", Provider: "spotify", Object: &json.RawMessage{}} + obj.Object.UnmarshalJSON(album.JSON()) + + results.Albums = append(results.Albums, obj) + } + } + if searchResponse.Results.Tracks.Total > 0 { + tracks := searchResponse.Results.Tracks.Hits + for i := 0; i < len(tracks); i++ { + stream := &ObjectStream{Name: tracks[i].Name} + stream.URI = tracks[i].Uri + for _, artist := range tracks[i].Artists { + objCreator := &ObjectCreator{Name: artist.Name, URI: artist.Uri} + obj := &Object{URI: artist.Uri, Type: "creator", Provider: "spotify", Object: &json.RawMessage{}} + obj.Object.UnmarshalJSON(objCreator.JSON()) + stream.Creators = append(stream.Creators, obj) + } + stream.Album = &Object{URI: tracks[i].Album.Uri, Type: "album", Provider: "spotify", Object: &json.RawMessage{}} + objAlbum := &ObjectAlbum{Name: tracks[i].Album.Name, URI: tracks[i].Album.Uri} + stream.Album.Object.UnmarshalJSON(objAlbum.JSON()) + stream.Artworks = []*ObjectArtwork{ + &ObjectArtwork{ + URL: tracks[i].Image, + }, + } + stream.Duration = int64(tracks[i].Duration) / 1000 + + objStream := &Object{URI: stream.URI, Type: "stream", Provider: "spotify", Object: &json.RawMessage{}} + objStream.Object.UnmarshalJSON(stream.JSON()) + results.Streams = append(results.Streams, objStream) + } + } + /*if searchResponse.Results.Playlists.Total > 0 { + playlists := searchResponse.Results.Playlists.Hits + for i := 0; i < len(playlists); i++ { + playlist := &ObjectPlaylist{ + Name: playlists[i].Name, + URI: playlists[i].Uri, + } + + results.Playlists = append(results.Playlists, &Object{Type: "playlist", Provider: "spotify", Object: playlist}) + } + }*/ + + return +} + +// gid2Id converts a given GID to an ID +func gid2Id(gid []byte) (id string) { + dstId := make([]byte, base64.StdEncoding.DecodedLen(len(gid))) + _, err := base64.StdEncoding.Decode(dstId, gid) + if err != nil { + //Error.Printf("Gid2Id: %v str(%v) err(%s)\n", gid, string(gid), err.Error()) + id = utils.ConvertTo62(gid) + return + } + id = utils.ConvertTo62(dstId) + return +} + +// id2Gid converts a given ID to a GID +func id2Gid(id string) (gid []byte) { + dstId := make([]byte, base64.StdEncoding.EncodedLen(len(id))) + base64.StdEncoding.Encode(dstId, []byte(id)) + if len(dstId) > 0 { + id = string(dstId) + } + gid = utils.Convert62(id) + return +} + +type SpotifyLyrics struct { + Colors *Colors + HasVocalRemoval bool + Lyrics *SpotifyLyricsInner +} + +type Colors struct { + Background json.Number + HighlightText json.Number + Text json.Number +} + +type SpotifyLyricsInner struct { + FullscreenAction string + IsDenseTypeface bool + IsRtlLanguage bool + Language string + Lines []*Line + Provider string + ProviderDisplayName string + ProviderLyricsID string + SyncLyricsURI string + SyncType json.Number //0=Unsynced,1=LineSynced +} + +type Line struct { + StartTimeMs string + EndTimeMs string + Words string + Syllables []json.RawMessage +} + +func (s *SpotifyClient) Transcribe(stream *ObjectStream) (err error) { + s.Lock() + defer s.Unlock() + + uri := fmt.Sprintf("hm://color-lyrics/v2/track/%s", stream.URI) + lyrics := &SpotifyLyrics{} + err = s.mercuryGetJson(uri, lyrics) + //Trace.Printf("Spotify Lyrics object: %v\n", lyrics) + return +} + +// ReplaceURI replaces all instances of a URI with a libremedia-acceptable URI +func (s *SpotifyClient) ReplaceURI(text string) string { + return spoturire.ReplaceAllStringFunc(text, spotifyReplaceURI) +} + +func spotifyReplaceURI(link string) string { + fmt.Println("Testing string: " + link) + match := spoturire.FindAllStringSubmatch(link, 1) + if len(match) > 0 { + typed := match[0][1] + id := match[0][2] + name := match[0][3] + switch typed { + case "album": + typed = "album" + case "artist": + typed = "creator" + case "search": + //TODO: Handle search URIs + return "[" + name + "](/search?q=" + name + ")" + } + return "[" + name + "](/" + typed + "?uri=spotify:" + typed + ":" + id + ")" + } + return link +} \ No newline at end of file diff --git a/tidal.go b/tidal.go new file mode 100644 index 0000000..65082a7 --- /dev/null +++ b/tidal.go @@ -0,0 +1,1271 @@ +package main + +/* +technical_names = { + 'eac3': 'E-AC-3 JOC (Dolby Digital Plus with Dolby Atmos, with 5.1 bed)', + 'mha1': 'MPEG-H 3D Audio (Sony 360 Reality Audio)', + 'ac4': 'AC-4 IMS (Dolby AC-4 with Dolby Atmos immersive stereo)', + 'mqa': 'MQA (Master Quality Authenticated) in FLAC container', + 'flac': 'FLAC (Free Lossless Audio Codec)', + 'alac': 'ALAC (Apple Lossless Audio Codec)', + 'mp4a.40.2': 'AAC 320 (Advanced Audio Coding) with a bitrate of 320kb/s', + 'mp4a.40.5': 'AAC 96 (Advanced Audio Coding) with a bitrate of 96kb/s' +} +*/ + +import ( + "context" + jsontwo "encoding/json" + "encoding/xml" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "sync" + "time" + + //Golang repos + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" + + //GitHub repos + "github.com/dsoprea/go-utility/filesystem" + "github.com/JoshuaDoes/json" +) + +const ( + tidalAuth = "https://auth.tidal.com/v1/oauth2" + tidalAPI = "https://api.tidal.com/v1/" + tidalImgURL = "https://resources.tidal.com/images/%s/%dx%d.jpg" + tidalVidURL = "https://resources.tidal.com/videos/%s/%dx%d.mp4" + tidalTracksItems = "100" //max 100 + tidalSearchItems = "10" //max 100 +) + +var ( + tidalSizesCreator = []int{160, 320, 480, 750} + tidalSizesAlbum = []int{80, 160, 320, 640, 1280} + + tidalurire = regexp.MustCompile(`\[wimpLink (.*?)="(.*?)"\](.*?)\[/wimpLink\]`) +) + +// TidalError holds an error from Tidal +type TidalError struct { + Status int `json:"status"` + SubStatus int `json:"sub_status"` + ErrorType string `json:"error"` + ErrorMsg string `json:"error_description"` +} + +// Error returns an error +func (terr *TidalError) Error() error { + return fmt.Errorf("%d:%d %s: %s", terr.Status, terr.SubStatus, terr.ErrorType, terr.ErrorMsg) +} + +// TidalClient holds a Tidal client +type TidalClient struct { + sync.Mutex + + ClientID string `json:"clientID"` + ClientSecret string `json:"clientSecret"` + + HTTP *oauth2.Transport `json:"-"` + Auth *TidalDeviceCode `json:"auth"` + Service *Service `json:"-"` +} + +func (t *TidalClient) Provider() string { + return "tidal" +} + +func (t *TidalClient) SetService(service *Service) { + t.Service = service +} + +func (t *TidalClient) Authenticate(cfg *HandlerConfig) (handler Handler, err error) { + if cfg.Username == "" || cfg.Password == "" || cfg.BlobPath == "" { + return nil, fmt.Errorf("tidal: must provide username, password, and path to file for storing auth blob") + } + id := cfg.Username + secret := cfg.Password + blobPath := cfg.BlobPath + + t, err = NewTidalBlob(blobPath) + if err != nil { + t, err = NewTidal(id, secret) + if err != nil { + return nil, fmt.Errorf("tidal: unable to receive device code for account linking: %v", err) + } else { + os.Stderr.Write([]byte("Please link your Tidal account to continue!\n- https://" + t.Auth.VerificationURIComplete + "\n")) + err = t.WaitForAuth() + if err != nil { + return nil, fmt.Errorf("tidal: unable to receive auth token: %v", err) + } + } + } else { + if t.NeedsAuth() { + os.Remove(blobPath) + err = t.NewDeviceCode() + if err != nil { + return nil, fmt.Errorf("tidal: unable to receive device code for account linking: %v", err) + } else { + os.Stderr.Write([]byte("Please link your Tidal account to continue!\n- https://" + t.Auth.VerificationURIComplete + "\n")) + err = t.WaitForAuth() + if err != nil { + return nil, fmt.Errorf("tidal: unable to receive auth token: %v", err) + } + } + } + } + + t.SaveBlob(blobPath) //Save the new token blob for future restarts + os.Stderr.Write([]byte("Authenticated to Tidal: Welcome\n")) + return t, nil +} + +func (t *TidalClient) Get(endpoint string, query url.Values) (*http.Response, error) { + t.Lock() + defer t.Unlock() + + if query == nil { + query = url.Values{} + } + query.Add("countryCode", t.Auth.CountryCode) + req, err := http.NewRequest("GET", tidalAPI+endpoint, nil) + if err != nil { + return nil, err + } + req.URL.RawQuery = query.Encode() + resp, err := t.HTTP.RoundTrip(req) + if err != nil { + return nil, err + } + return resp, nil +} + +// GetJSON gets an authenticated JSON resource from a Tidal endpoint and writes it to a target interface +func (t *TidalClient) GetJSON(endpoint string, query url.Values, target interface{}) error { + resp, err := t.Get(endpoint, query) + if err != nil { + return err + } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + //Trace.Println("Tidal:", resp.Status, endpoint, "\n", string(body)) + if resp.StatusCode != 200 { + return fmt.Errorf("%s: %s", resp.Status, string(body)) + } + if target != nil { + return json.Unmarshal(body, target) + } + return nil +} + +// TidalArtist holds a Tidal artist +type TidalArtist struct { + ID jsontwo.Number `json:"id"` + Name string `json:"name"` + ArtistTypes []string `json:"artistTypes,omitempty"` + Albums []TidalAlbum `json:"albums,omitempty"` + EPsAndSingles []TidalAlbum `json:"epsandsingles,omitempty"` + Picture string `json:"picture,omitempty"` +} + +// TidalArtistAlbums holds a Tidal artist's album list +type TidalArtistAlbums struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []TidalAlbum `json:"items"` +} + +// TidalTracks holds a Tidal track list +type TidalTracks struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []TidalTrack `json:"items"` +} + +// TidalBio holds a Tidal artist's biography +type TidalBio struct { + Text string `json:"text"` +} + +// Creator gets an artist object from Tidal +func (t *TidalClient) Creator(creatorID string) (creator *ObjectCreator, err error) { + tCreator := &TidalArtist{} + err = t.GetJSON("artists/"+creatorID, nil, &tCreator) + if err != nil { + return nil, err + } + tBio := &TidalBio{} + _ = t.GetJSON("artists/"+creatorID+"/bio", nil, &tBio) + if tBio.Text != "" { + tBio.Text = t.ReplaceURI(tBio.Text) + } + tTopTracks := &TidalTracks{} + tracksFilter := url.Values{} + tracksFilter.Set("limit", tidalTracksItems) + err = t.GetJSON("artists/"+creatorID+"/toptracks", tracksFilter, &tTopTracks) + if err != nil { + return nil, err + } + topTracks := make([]*Object, len(tTopTracks.Items)) + for i := 0; i < len(topTracks); i++ { + tTrack := tTopTracks.Items[i] + tCreators := make([]*Object, len(tTrack.Artists)) + for j := 0; j < len(tCreators); j++ { + objCreator := &ObjectCreator{ + URI: "tidal:artist:" + tTrack.Artists[j].ID.String(), + Name: tTrack.Artists[j].Name, + } + tCreators[j] = &Object{ + URI: "tidal:artist:" + tTrack.Artists[j].ID.String(), + Type: "creator", + Provider: "tidal", + Object: &jsontwo.RawMessage{}, + } + tCreators[j].Object.UnmarshalJSON(objCreator.JSON()) + } + trackNum, err := tTrack.TrackNumber.Int64() + if err != nil { + return nil, err + } + duration, err := tTrack.Duration.Int64() + if err != nil { + return nil, err + } + objStream := &ObjectStream{ + URI: "tidal:track:" + tTopTracks.Items[i].ID.String(), + ID: tTopTracks.Items[i].ID.String(), + Name: tTopTracks.Items[i].Title, + Track: int(trackNum), + Duration: duration, + Creators: tCreators, + Album: &Object{ + URI: "tidal:album:" + tTrack.Album.ID.String(), + Type: "album", + Provider: "tidal", + Object: &jsontwo.RawMessage{}, + }, + } + objAlbum := &ObjectAlbum{ + URI: "tidal:album:" + tTrack.Album.ID.String(), + Name: tTrack.Album.Title, + } + objStream.Album.Object.UnmarshalJSON(objAlbum.JSON()) + topTracks[i] = &Object{ + URI: "tidal:track:" + tTopTracks.Items[i].ID.String(), + Type: "stream", + Provider: "tidal", + Object: &jsontwo.RawMessage{}, + } + topTracks[i].Object.UnmarshalJSON(objStream.JSON()) + } + tAlbums := TidalArtistAlbums{} + albumFilter := url.Values{} + albumFilter.Set("limit", tidalTracksItems) + err = t.GetJSON("artists/"+creatorID+"/albums", albumFilter, &tAlbums) + if err == nil && len(tAlbums.Items) > 0 { + tCreator.Albums = append(tCreator.Albums, tAlbums.Items...) + } + albums := make([]*Object, len(tCreator.Albums)) + for i := 0; i < len(albums); i++ { + tAlbum := tCreator.Albums[i] + objAlbum := &ObjectAlbum{ + URI: "tidal:album:" + tAlbum.ID.String(), + Name: tAlbum.Title, + } + albums[i] = &Object{ + URI: "tidal:album:" + tAlbum.ID.String(), + Type: "album", + Provider: "tidal", + Object: &jsontwo.RawMessage{}, + } + albums[i].Object.UnmarshalJSON(objAlbum.JSON()) + } + epsandsingles := TidalArtistAlbums{} + albumFilter.Set("filter", "EPSANDSINGLES") + err = t.GetJSON("artists/"+creatorID+"/albums", albumFilter, &epsandsingles) + if err == nil && len(epsandsingles.Items) > 0 { + tCreator.EPsAndSingles = append(tCreator.EPsAndSingles, epsandsingles.Items...) + } + singles := make([]*Object, len(tCreator.EPsAndSingles)) + for i := 0; i < len(singles); i++ { + tSingle := tCreator.EPsAndSingles[i] + objAlbum := &ObjectAlbum{ + URI: "tidal:album:" + tSingle.ID.String(), + Name: tSingle.Title, + } + singles[i] = &Object{ + URI: "tidal:album:" + tSingle.ID.String(), + Type: "album", + Provider: "tidal", + Object: &jsontwo.RawMessage{}, + } + singles[i].Object.UnmarshalJSON(objAlbum.JSON()) + } + creator = &ObjectCreator{ + URI: "tidal:artist:" + creatorID, + Name: tCreator.Name, + Description: tBio.Text, + TopStreams: topTracks, + Albums: albums, + Singles: singles, + Artworks: t.ArtworkImg(tCreator.Picture, tidalSizesCreator), + } + return +} + +// TidalAlbum holds a Tidal album +type TidalAlbum struct { + ID jsontwo.Number `json:"id"` + Title string `json:"title"` + Duration jsontwo.Number `json:"duration,omitempty"` + NumberOfTracks jsontwo.Number `json:"numberOfTracks,omitempty"` + NumberOfVideos jsontwo.Number `json:"numberOfVideos,omitempty"` + NumberOfVolumes jsontwo.Number `json:"numberOfVolumes,omitempty"` + ReleaseDate string `json:"releaseDate,omitempty"` + Copyright string `json:"copyright,omitempty"` + Explicit bool `json:"explicit,omitempty"` + AudioQuality string `json:"audioQuality,omitempty"` + AudioModes []string `json:"audioModes,omitempty"` //usually just STEREO + Artists []TidalArtist `json:"artists,omitempty"` + Tracks []TidalTrack `json:"tracks,omitempty"` + Cover string `json:"cover,omitempty"` //An image cover for the album + VideoCover string `json:"videoCover,omitempty"` //A video cover for the album +} + +// TidalAlbumTracks holds a Tidal album's track list +type TidalAlbumTracks struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []struct { + Item TidalTrack `json:"item"` + } `json:"items"` +} + +// Album gets an album object from Tidal +func (t *TidalClient) Album(albumID string) (album *ObjectAlbum, err error) { + tAlbum := &TidalAlbum{} + err = t.GetJSON("albums/"+albumID, nil, &tAlbum) + if err != nil { + return nil, err + } + creators := make([]*Object, len(tAlbum.Artists)) + for i := 0; i < len(creators); i++ { + objCreator := &ObjectCreator{ + URI: "tidal:artist:" + tAlbum.Artists[i].ID.String(), + Name: tAlbum.Artists[i].Name, + } + creators[i] = &Object{ + URI: "tidal:artist:" + tAlbum.Artists[i].ID.String(), + Type: "creator", + Provider: "tidal", + Object: &jsontwo.RawMessage{}, + } + creators[i].Object.UnmarshalJSON(objCreator.JSON()) + } + tracks := TidalAlbumTracks{} + albumFilter := url.Values{} + albumFilter.Set("limit", tidalTracksItems) + err = t.GetJSON("albums/"+albumID+"/items", albumFilter, &tracks) + if err == nil && len(tracks.Items) > 0 { + for _, item := range tracks.Items { + tAlbum.Tracks = append(tAlbum.Tracks, item.Item) + } + } + discs := []*ObjectDisc{ + { + Streams: make([]*Object, len(tAlbum.Tracks)), + Disc: 1, + Name: tAlbum.Title, + }, + } + for i := 0; i < len(tAlbum.Tracks); i++ { + tTrack := tAlbum.Tracks[i] + tCreators := make([]*Object, len(tTrack.Artists)) + for j := 0; j < len(tCreators); j++ { + objCreator := &ObjectCreator{ + URI: "tidal:artist:" + tTrack.Artists[j].ID.String(), + Name: tTrack.Artists[j].Name, + } + tCreators[j] = &Object{ + URI: "tidal:artist:" + tTrack.Artists[j].ID.String(), + Type: "creator", + Provider: "tidal", + Object: &jsontwo.RawMessage{}, + } + tCreators[j].Object.UnmarshalJSON(objCreator.JSON()) + } + trackNum, err := tTrack.TrackNumber.Int64() + if err != nil { + return nil, err + } + duration, err := tTrack.Duration.Int64() + if err != nil { + return nil, err + } + objStream := &ObjectStream{ + URI: "tidal:track:" + tTrack.ID.String(), + ID: tTrack.ID.String(), + Name: tTrack.Title, + Track: int(trackNum), + Duration: duration, + Creators: tCreators, + Album: &Object{ + URI: "tidal:album:" + albumID, + Type: "album", + Provider: "tidal", + Object: &jsontwo.RawMessage{}, + }, + } + objAlbum := &ObjectAlbum{ + URI: "tidal:album:" + albumID, + Name: tAlbum.Title, + Creators: creators, + } + objStream.Album.Object.UnmarshalJSON(objAlbum.JSON()) + discs[0].Streams[i] = &Object{ + URI: "tidal:track:" + tTrack.ID.String(), + Type: "stream", + Provider: "tidal", + Object: &jsontwo.RawMessage{}, + } + discs[0].Streams[i].Object.UnmarshalJSON(objStream.JSON()) + } + album = &ObjectAlbum{ + URI: "tidal:album:" + albumID, + Name: tAlbum.Title, + Creators: creators, + Discs: discs, + Copyrights: []string{tAlbum.Copyright}, + Label: tAlbum.Copyright, + Artworks: make([]*ObjectArtwork, 0), + DateTime: tAlbum.ReleaseDate, + } + album.Artworks = append(album.Artworks, t.ArtworkImg(tAlbum.Cover, tidalSizesAlbum)...) + album.Artworks = append(album.Artworks, t.ArtworkVid(tAlbum.VideoCover, tidalSizesAlbum)...) + return +} + +// TidalTrack holds a Tidal track +type TidalTrack struct { + //Artist TidalArtist `json:"artist"` + Artists []TidalArtist `json:"artists,omitempty"` + Album TidalAlbum `json:"album,omitempty"` + Title string `json:"title"` + ID jsontwo.Number `json:"id"` + Explicit bool `json:"explicit,omitempty"` + Copyright string `json:"copyright,omitempty"` + Popularity int `json:"popularity,omitempty"` + TrackNumber jsontwo.Number `json:"trackNumber,omitempty"` + Duration jsontwo.Number `json:"duration"` + AudioQuality string `json:"audioQuality"` +} + +// Stream gets a track object from Tidal +func (t *TidalClient) Stream(trackID string) (stream *ObjectStream, err error) { + tTrack := &TidalTrack{} + err = t.GetJSON("tracks/"+trackID, nil, &tTrack) + if err != nil { + return nil, err + } + creators := make([]*Object, len(tTrack.Artists)) + for i := 0; i < len(creators); i++ { + objCreator := &ObjectCreator{ + URI: "tidal:artist:" + tTrack.Artists[i].ID.String(), + Name: tTrack.Artists[i].Name, + } + creators[i] = &Object{ + URI: "tidal:artist:" + tTrack.Artists[i].ID.String(), + Type: "creator", + Provider: "tidal", + Object: &jsontwo.RawMessage{}, + } + creators[i].Object.UnmarshalJSON(objCreator.JSON()) + } + formats := make([]*ObjectFormat, 0) + formatList := t.FormatList() + for i := 0; i < len(formatList); i++ { + formats = append(formats, formatList[i]) //Assume the quality exists, bail down the ladder before playback + } + duration, err := tTrack.Duration.Int64() + if err != nil { + return nil, err + } + stream = &ObjectStream{ + Provider: t.Provider(), + URI: "tidal:track:" + trackID, + ID: trackID, + Name: tTrack.Title, + Creators: creators, + Album: &Object{ + URI: "tidal:album:" + tTrack.Album.ID.String(), + Type: "album", + Provider: "tidal", + Object: &jsontwo.RawMessage{}, + }, + Duration: duration, + Formats: formats, + } + objAlbum := &ObjectAlbum{ + URI: "tidal:album:" + tTrack.Album.ID.String(), + Name: tTrack.Album.Title, + } + stream.Album.Object.UnmarshalJSON(objAlbum.JSON()) + return +} + +// TidalVideo holds a Tidal video +type TidalVideo struct { + Title string `json:"title"` + ID jsontwo.Number `json:"id"` + Artists []TidalArtist `json:"artists,omitempty"` + Album TidalAlbum `json:"album,omitempty"` + Duration jsontwo.Number `json:"duration"` +} + +// TidalPlaylist holds a Tidal playlist +type TidalPlaylist struct { + Title string `json:"title"` + UUID string `json:"uuid"` + NumberOfTracks jsontwo.Number `json:"numberOfTracks"` + NumberOfVideos jsontwo.Number `json:"numberOfVideos"` + Creator struct { + ID jsontwo.Number `json:"id"` + } `json:"creator"` + Description string `json:"description"` + Duration jsontwo.Number `json:"duration"` + //LastUpdated time.Time `json:"lastUpdated"` + //Created time.Time `json:"created"` + Type string `json:"type"` //USER + PublicPlaylist bool `json:"publicPlaylist"` + URL string `json:"url"` + Image string `json:"image"` + Popularity jsontwo.Number `json:"popularity"` + SquareImage string `json:"squareImage"` + PromotedArtists []TidalArtist `json:"promotedArtists"` + //LastItemAddedAt time.Time `json:"lastItemAddedAt"` + Tracks []TidalTrack `json:"tracks,omitempty"` +} + +// TidalPlaylistTracks holds a Tidal Playlist's track list +type TidalPlaylistTracks struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []struct { + Item TidalTrack `json:"item"` + } `json:"items"` +} + +// GetPlaylist gets a playlist object from Tidal +func (t *TidalClient) GetPlaylist(playlistID string) (playlist *TidalPlaylist, err error) { + err = t.GetJSON("playlists/"+playlistID, nil, &playlist) + if err != nil { + return nil, err + } + + tracks := TidalPlaylistTracks{} + err = t.GetJSON("playlists/"+playlistID+"/items", nil, &tracks) + if err == nil && len(tracks.Items) > 0 { + for _, item := range tracks.Items { + playlist.Tracks = append(playlist.Tracks, item.Item) + } + } + + return playlist, err +} + +// GetVideo gets a video object from Tidal +func (t *TidalClient) GetVideo(videoID string) (video *TidalVideo, err error) { + err = t.GetJSON("videos/"+videoID, nil, &video) + return video, err +} + +type TidalLyrics struct { + TrackID jsontwo.Number `json:"trackId"` + Provider string `json:"lyricsProvider"` + ProviderTrackID jsontwo.Number `json:"providerCommontrackId"` + ProviderLyricsID jsontwo.Number `json:"providerLyricsId"` + RightToLeft bool `json:"isRightToLeft"` + Text string `json:"text"` + Subtitles string `json:"subtitles"` +} + +// GetLyrics gets a lyrics object from Tidal +func (t *TidalClient) Transcribe(stream *ObjectStream) (err error) { + lyrics := &TidalLyrics{} + uri := fmt.Sprintf("tracks/%s/lyrics", stream.ID) + reqForm := url.Values{} + reqForm.Set("deviceType", "BROWSER") + reqForm.Set("locale", "en_US") + err = t.GetJSON(uri, reqForm, &lyrics) + if err != nil { + return + } + //Trace.Printf("Tidal Lyrics object: %v\n", lyrics) + + text := lyrics.Text + if text == "" { + text = lyrics.Subtitles + if text == "" { + Error.Println("Tidal: Failed to find text or subtitles for " + stream.URI) + return + } + } + + lines := strings.Split(text, "\n") + objTranscript := &ObjectTranscript{ + RightToLeft: lyrics.RightToLeft, + Provider: lyrics.Provider, + ProviderLyricsID: lyrics.ProviderLyricsID.String(), + ProviderTrackID: lyrics.ProviderTrackID.String(), + TimeSynced: false, + Lines: make([]*ObjectTranscriptLine, 0), + } + for i := 0; i < len(lines); i++ { + line := lines[i] + var min, sec, ms = 0, 0, 0 + n, _ := fmt.Sscanf(line, "[%d:%d.%d]", &min, &sec, &ms) + if n == 3 { + objTranscript.TimeSynced = true + startTimeMs := (min * 60 * 1000) + (sec * 1000) + (ms * 10) + txt := strings.Split(line, "] ")[1] + objTranscript.Lines = append(objTranscript.Lines, &ObjectTranscriptLine{ + StartTimeMs: startTimeMs, + Text: txt, + }) + } else { + objTranscript.Lines = append(objTranscript.Lines, &ObjectTranscriptLine{Text: line}) + } + } + if objTranscript.TimeSynced { + Trace.Println("Tidal: Successfully time synced " + stream.URI) + } else { + Trace.Println("Tidal: Failed to find time sync data for " + stream.URI) + } + + stream.Transcript = objTranscript + return +} + +func (t *TidalClient) StreamFormat(w http.ResponseWriter, r *http.Request, stream *ObjectStream, format int) (err error) { + objFormat := stream.GetFormat(format) + if objFormat == nil { + return fmt.Errorf("tidal: unknown format %d for stream %s", format, stream.ID) + } + manifest, err := t.GetAudioStream(stream.ID, objFormat.Name) + if err != nil { + return fmt.Errorf("tidal: unable to retrieve audio stream for stream %s at %s quality", stream.ID, objFormat.ID) + } + w.Header().Set("Content-Type", manifest.MimeType) + for i := 0; i < len(manifest.URLs); i++ { + req, err := http.NewRequest("GET", manifest.URLs[i], nil) + if err != nil { + jsonWriteErrorf(w, 500, "tidal: failed to create stream endpoint: %v", err) + return err + } + resp, err := t.HTTP.RoundTrip(req) + if err != nil { + jsonWriteErrorf(w, 500, "tidal: failed to start stream: %v", err) + return err + } + buf, err := io.ReadAll(resp.Body) + if err != nil { + errMsg := err.Error() + if strings.Contains(errMsg, "broken pipe") || strings.Contains(errMsg, "connection reset") { + return nil //The stream was successful, but interrupted + } + jsonWriteErrorf(w, 500, "tidal: failed to copy stream: %v", err) + return err + } + streamer := rifs.NewSeekableBufferWithBytes(buf) + http.ServeContent(w, r, stream.ID, time.Time{}, streamer) + //_, err = io.Copy(w, resp.Body) + } + return nil +} + +// GetAudioStream gets the stream for a given audio on Tidal +func (t *TidalClient) GetAudioStream(trackID, quality string) (manifest *TidalAudioManifest, err error) { + reqForm := url.Values{} + reqForm.Set("audioquality", quality) + reqForm.Set("urlusagemode", "STREAM") + reqForm.Set("assetpresentation", "FULL") + err = t.GetJSON("tracks/"+trackID+"/urlpostpaywall", reqForm, &manifest) + if err != nil { + return nil, err + } + manifest.Quality = quality + manifest.Codecs = "flac" + manifest.MimeType = "audio/mp4" + return manifest, nil + + /*reqForm := url.Values{} + reqForm.Set("audioquality", quality) + reqForm.Set("playbackmode", "STREAM") + reqForm.Set("assetpresentation", "FULL") + reqForm.Set("prefetch", "false") + + err = t.GetJSON("tracks/"+trackID+"/playbackinfopostpaywall", reqForm, &stream) + if err != nil { + return nil, err + } + + if needURL { + decodedManifest, err := base64.StdEncoding.DecodeString(stream.ManifestBase64) + if err != nil { + return nil, fmt.Errorf("error decoding manifest of type %s: %v", stream.ManifestMimeType, err) + } + Trace.Printf("Tidal's decoded manifest for %s/%s:\n\n%s\n\n", trackID, quality, decodedManifest) + + if strings.Contains(stream.ManifestMimeType, "vnd.t.bt") { + err = json.Unmarshal(decodedManifest, &stream.Manifest) + if err != nil { + return nil, fmt.Errorf("error unmarshalling vnd.t.bt manifest: %v\n\n%s", err, decodedManifest) + } + } else if stream.ManifestMimeType == "application/dash+xml" { + //return nil, fmt.Errorf("not yet unmarshalling dash+xml:\n\n%s", string(decodedManifest)) + dashXML := &TidalAudioDashXML{} + err = xml.Unmarshal([]byte(decodedManifest), &dashXML) + if err != nil { + return nil, fmt.Errorf("error unmarshalling dash+xml manifest: %v\n\n%s", err, decodedManifest) + } + stream.Manifest = &TidalAudioManifest{ + MimeType: dashXML.Period.AdaptationSet.MimeType, + Codecs: dashXML.Period.AdaptationSet.Representation.Codecs, + URLs: []string{ + dashXML.Period.AdaptationSet.Representation.SegmentTemplate.Initialization, + dashXML.Period.AdaptationSet.Representation.SegmentTemplate.Media, + }, + } + } else { + return nil, fmt.Errorf("unsupported manifest type: %s", stream.ManifestMimeType) + } + } + + return stream, nil*/ +} + +// GetVideoStream gets the stream for a given video on Tidal +func (t *TidalClient) GetVideoStream(videoID, quality string) (stream *TidalVideoStream, err error) { + err = t.GetJSON("videos/"+videoID+"/streamurl", nil, &stream) + return stream, err +} + +// TidalAudioStream holds a Tidal audio stream +type TidalAudioStream struct { + TrackID jsontwo.Number `json:"trackId"` + AssetPresentation string `json:"assetPresentation"` + AudioMode string `json:"audioMode"` + AudioQuality string `json:"audioQuality"` + ManifestMimeType string `json:"manifestMimeType"` + ManifestHash string `json:"manifestHash"` + ManifestBase64 string `json:"manifest"` //base64-encoded audio metadata + Manifest *TidalAudioManifest `json:"-"` + AlbumReplayGain jsontwo.Number `json:"albumReplayGain"` + AlbumPeakAmplitude jsontwo.Number `json:"albumPeakAmplitude"` + TrackReplayGain jsontwo.Number `json:"trackReplayGain"` + TrackPeakAmplitude jsontwo.Number `json:"trackPeakAmplitude"` +} + +// TidalAudioManifest holds a Tidal audio stream's metadata manifest +type TidalAudioManifest struct { + Quality string `json:"-"` + MimeType string `json:"mimeType"` + Codecs string `json:"codecs"` + EncryptionType string `json:"encryptionType"` + URLs []string `json:"urls"` +} + +// TidalAudioDashXML was generated 2023-04-19 23:21:39 by https://xml-to-go.github.io/ in Ukraine. +type TidalAudioDashXML struct { + XMLName xml.Name `xml:"MPD" json:"mpd,omitempty"` + Text string `xml:",chardata" json:"text,omitempty"` + Xmlns string `xml:"xmlns,attr" json:"xmlns,omitempty"` + Xsi string `xml:"xsi,attr" json:"xsi,omitempty"` + Xlink string `xml:"xlink,attr" json:"xlink,omitempty"` + Cenc string `xml:"cenc,attr" json:"cenc,omitempty"` + SchemaLocation string `xml:"schemaLocation,attr" json:"schemalocation,omitempty"` + Profiles string `xml:"profiles,attr" json:"profiles,omitempty"` + Type string `xml:"type,attr" json:"type,omitempty"` + MinBufferTime string `xml:"minBufferTime,attr" json:"minbuffertime,omitempty"` + MediaPresentationDuration string `xml:"mediaPresentationDuration,attr" json:"mediapresentationduration,omitempty"` + Period struct { + Text string `xml:",chardata" json:"text,omitempty"` + ID string `xml:"id,attr" json:"id,omitempty"` + AdaptationSet struct { + Text string `xml:",chardata" json:"text,omitempty"` + ID string `xml:"id,attr" json:"id,omitempty"` + ContentType string `xml:"contentType,attr" json:"contenttype,omitempty"` + MimeType string `xml:"mimeType,attr" json:"mimetype,omitempty"` //MimeType + SegmentAlignment string `xml:"segmentAlignment,attr" json:"segmentalignment,omitempty"` + Representation struct { + Text string `xml:",chardata" json:"text,omitempty"` + ID string `xml:"id,attr" json:"id,omitempty"` + Codecs string `xml:"codecs,attr" json:"codecs,omitempty"` //Codecs + Bandwidth string `xml:"bandwidth,attr" json:"bandwidth,omitempty"` + AudioSamplingRate string `xml:"audioSamplingRate,attr" json:"audiosamplingrate,omitempty"` + SegmentTemplate struct { + Text string `xml:",chardata" json:"text,omitempty"` + Timescale string `xml:"timescale,attr" json:"timescale,omitempty"` + Initialization string `xml:"initialization,attr" json:"initialization,omitempty"` + Media string `xml:"media,attr" json:"media,omitempty"` //URLs[0] + StartNumber string `xml:"startNumber,attr" json:"startnumber,omitempty"` + SegmentTimeline struct { + Text string `xml:",chardata" json:"text,omitempty"` + S []struct { + Text string `xml:",chardata" json:"text,omitempty"` + D string `xml:"d,attr" json:"d,omitempty"` + R string `xml:"r,attr" json:"r,omitempty"` + } `xml:"S" json:"s,omitempty"` + } `xml:"SegmentTimeline" json:"segmenttimeline,omitempty"` + } `xml:"SegmentTemplate" json:"segmenttemplate,omitempty"` + } `xml:"Representation" json:"representation,omitempty"` + } `xml:"AdaptationSet" json:"adaptationset,omitempty"` + } `xml:"Period" json:"period,omitempty"` +} + +func (t *TidalClient) FormatList() (formats []*ObjectFormat) { + formats = []*ObjectFormat{ + &ObjectFormat{ + ID: 0, + Name: "HI_RES", + Format: "flac", + Codec: "flac", + BitRate: 9216000, + BitDepth: 24, + SampleRate: 96000, + }, + &ObjectFormat{ + ID: 1, + Name: "LOSSLESS", + Format: "flac", + Codec: "flac", + BitRate: 1411000, + BitDepth: 24, + SampleRate: 44100, + }, + &ObjectFormat{ + ID: 2, + Name: "HIGH", + Format: "flac", + Codec: "flac", + BitRate: 320000, + BitDepth: 16, + SampleRate: 44100, + }, + &ObjectFormat{ + ID: 3, + Name: "LOW", + Format: "flac", + Codec: "flac", + BitRate: 96000, + BitDepth: 16, + SampleRate: 44100, + }, + } + return +} + +// TidalVideoStream holds a Tidal video stream +type TidalVideoStream struct { + VideoID jsontwo.Number `json:"videoId"` + StreamType string `json:"streamType"` + AssetPresentation string `json:"assetPresentation"` + VideoQuality string `json:"videoQuality"` + ManifestMimeType string `json:"manifestMimeType"` + ManifestHash string `json:"manifestHash"` + ManifestBase64 string `json:"manifest"` + Manifest *TidalVideoManifest `json:"-"` +} + +// TidalVideoManifest holds a Tidal video stream's metadata manifest +type TidalVideoManifest struct { + MimeType string `json:"mimeType"` + Codecs string `json:"codecs"` + EncryptionType string `json:"encryptionType"` + URLs []string `json:"urls"` +} + +/*func tidalGenerateVideoFormats(videoID, topQuality string) []*ObjectFormat { + //streamURLPrefix := "DOMAIN/v1/stream/tidal:video:" + videoID + "?format=" + + formats := make([]*ObjectFormat, 0) + possibleVideoQualities := []string{"LOW", "HIGH"} + + for i, quality := range possibleVideoQualities { + tidalStream, err := t.GetVideoStream(videoID, quality) + if err != nil { + Error.Println(err) + continue + } + + tidalFormat := &ObjectFormat{ + ID: i, + Name: tidalStream.VideoQuality, + URL: tidalStream.Manifest.URLs[0], //streamURLPrefix + tidalStream.VideoQuality, + Format: strings.Split(tidalStream.Manifest.MimeType, "/")[1], + Codec: tidalStream.Manifest.Codecs, + } + + formats = append(formats, tidalFormat) + if quality == topQuality { + break + } + } + + return formats +}*/ + +func (t *TidalClient) ArtworkImg(coverID string, sizes []int) []*ObjectArtwork { + return t.Artwork(coverID, tidalImgURL, "jpg", sizes) +} +func (t *TidalClient) ArtworkVid(coverID string, sizes []int) []*ObjectArtwork { + return t.Artwork(coverID, tidalVidURL, "mp4", sizes) +} +func (t *TidalClient) Artwork(coverID, coverURL, fileType string, sizes []int) (artworks []*ObjectArtwork) { + if coverID == "" || fileType == "" { + return nil + } + coverID = strings.ReplaceAll(coverID, "-", "/") + artworks = make([]*ObjectArtwork, len(sizes)) + for i := 0; i < len(sizes); i++ { + width := sizes[i] + height := width + url := fmt.Sprintf(coverURL, coverID, width, height) + artworks[i] = NewObjArtwork(t.Provider(), fileType, url, width, height) + } + return artworks +} + +// TidalSearchResults holds the Tidal results for a given search query +type TidalSearchResults struct { + Artists struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []TidalArtist `json:"items"` + } `json:"artists"` + Albums struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []TidalAlbum `json:"items"` + } `json:"albums"` + Playlists struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []TidalPlaylist `json:"items"` + } `json:"playlists"` + Tracks struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []TidalTrack `json:"items"` + } `json:"tracks"` + Videos struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []TidalVideo `json:"items"` + } `json:"videos"` +} + +// Search returns the results for a given search query +func (t *TidalClient) Search(query string) (results *ObjectSearchResults, err error) { + results = &ObjectSearchResults{} + searchResults := TidalSearchResults{} + + if t == nil { + return results, nil + } + + reqForm := url.Values{} + reqForm.Set("query", query) + reqForm.Set("limit", tidalSearchItems) + + //types := []string{"TRACKS", "ARTISTS", "ALBUMS", "PLAYLISTS"} + types := []string{"TRACKS", "ARTISTS", "ALBUMS"} + for i := 0; i < len(types); i++ { + reqForm.Set("type", types[i]) + err = t.GetJSON("search", reqForm, &searchResults) + if err != nil { + return results, err + } + + if searchResults.Artists.TotalNumberOfItems > 0 { + artists := searchResults.Artists.Items + for i := 0; i < len(artists); i++ { + creator := &ObjectCreator{ + Name: artists[i].Name, + URI: "tidal:artist:" + artists[i].ID.String(), + } + objCreator := &Object{URI: creator.URI, Type: "creator", Provider: "tidal", Object: &jsontwo.RawMessage{}} + objCreator.Object.UnmarshalJSON(creator.JSON()) + results.Creators = append(results.Creators, objCreator) + } + } + if searchResults.Albums.TotalNumberOfItems > 0 { + albums := searchResults.Albums.Items + for i := 0; i < len(albums); i++ { + album := &ObjectAlbum{ + Name: albums[i].Title, + URI: "tidal:album:" + albums[i].ID.String(), + } + objAlbum := &Object{URI: album.URI, Type: "album", Provider: "tidal", Object: &jsontwo.RawMessage{}} + objAlbum.Object.UnmarshalJSON(album.JSON()) + results.Albums = append(results.Creators, objAlbum) + } + } + if searchResults.Tracks.TotalNumberOfItems > 0 { + tracks := searchResults.Tracks.Items + for i := 0; i < len(tracks); i++ { + stream := &ObjectStream{Name: tracks[i].Title} + stream.URI = "tidal:track:" + tracks[i].ID.String() + for _, artist := range tracks[i].Artists { + objCreator := &ObjectCreator{Name: artist.Name, URI: "tidal:artist:" + artist.ID.String()} + obj := &Object{URI: "tidal:artist:" + artist.ID.String(), Type: "creator", Provider: "tidal", Object: &jsontwo.RawMessage{}} + obj.Object.UnmarshalJSON(objCreator.JSON()) + stream.Creators = append(stream.Creators, obj) + } + stream.Album = &Object{URI: "tidal:album:" + tracks[i].Album.ID.String(), Type: "album", Provider: "tidal", Object: &jsontwo.RawMessage{}} + objAlbum := &ObjectAlbum{Name: tracks[i].Album.Title, URI: "tidal:album:" + tracks[i].Album.ID.String()} + stream.Album.Object.UnmarshalJSON(objAlbum.JSON()) + stream.Duration, err = tracks[i].Duration.Int64() + if err != nil { + return results, err + } + objStream := &Object{URI: stream.URI, Type: "stream", Provider: "tidal", Object: &jsontwo.RawMessage{}} + objStream.Object.UnmarshalJSON(stream.JSON()) + results.Streams = append(results.Streams, objStream) + } + } + /*if searchResults.Playlists.TotalNumberOfItems > 0 { + playlists := searchResults.Playlists.Items + for i := 0; i < len(playlists); i++ { + playlist := &ObjectPlaylist{ + Name: playlists[i].Title, + URI: "tidal:playlist:" + playlists[i].UUID, + } + + results.Playlists = append(results.Playlists, &Object{Type: "playlist", Provider: "tidal", Object: playlist}) + } + }*/ + /*if searchResults.Videos.TotalNumberOfItems > 0 { + videos := searchResults.Videos.Items + for i := 0; i < len(videos); i++ { + stream := &ObjectStream{Name: videos[i].Title} + stream.URI = "tidal:video:" + videos[i].ID.String() + for _, artist := range videos[i].Artists { + stream.Creators = append(stream.Creators, &Object{Type: "creator", Provider: "tidal", Object: &ObjectCreator{Name: artist.Name, URI: "tidal:artist:" + artist.ID.String()}}) + } + stream.Album = &Object{Type: "album", Provider: "tidal", Object: &ObjectAlbum{Name: videos[i].Album.Title, URI: "tidal:album:" + videos[i].Album.ID.String()}} + stream.Duration, err = videos[i].Duration.Int64() + if err != nil { + return results, err + } + + results.Streams = append(results.Streams, &Object{Type: "stream", Provider: "tidal", Object: stream}) + } + }*/ + } + + return results, nil +} + +// TidalDeviceCode holds the response to a device authorization request +type TidalDeviceCode struct { + StartTime time.Time `json:"-"` //Used to expire this code when time.Now() > AuthStart + ExpiresIn + Token *oauth2.Token `json:"token"` //Holds the token for communicating with authenticated Tidal content + OAuth2Cfg *clientcredentials.Config `json:"oauth2"` //Holds the OAuth2 configuration for this device code + + DeviceCode string `json:"deviceCode"` + //UserCode string `json:"userCode"` + //VerificationURI string `json:"verificationUri"` + VerificationURIComplete string `json:"verificationUriComplete"` + ExpiresIn float64 `json:"expiresIn"` + Interval int64 `json:"interval"` + + //UserID int64 `json:"userID"` + CountryCode string `json:"countryCode"` +} + +// NeedsAuth returns true if client needs authenticating +func (t *TidalClient) NeedsAuth() bool { + if t.Auth == nil || t.Auth.Token == nil || !t.Auth.Token.Valid() || t.Auth.OAuth2Cfg == nil || t.Auth.DeviceCode == "" || t.Auth.CountryCode == "" { + return true + } + return false +} + +// NewDeviceCode tries to get a new device code for user account pairing +func (t *TidalClient) NewDeviceCode() error { + t.ClearDeviceCode() + + reqForm := url.Values{} + reqForm.Set("client_id", t.ClientID) + reqForm.Set("scope", "r_usr+w_usr+w_sub") + resp, err := http.PostForm(tidalAuth+"/device_authorization", reqForm) + if err != nil { + return err + } + + if err := json.UnmarshalBody(resp, &t.Auth); err != nil { + return err + } + + t.Auth.StartTime = time.Now() + + reqForm = url.Values{} + reqForm.Set("device_code", t.Auth.DeviceCode) + reqForm.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + + t.Auth.OAuth2Cfg = &clientcredentials.Config{ + ClientID: t.ClientID, + ClientSecret: t.ClientSecret, + TokenURL: tidalAuth + "/token", + Scopes: []string{ + "r_usr", "w_usr", "w_sub", + }, + EndpointParams: reqForm, + AuthStyle: oauth2.AuthStyleInParams, + } + + return nil +} + +// WaitForAuth checks for a token pairing at regular intervals, failing if the device code expires +func (t *TidalClient) WaitForAuth() error { + if t.Auth == nil || t.Auth.OAuth2Cfg == nil { + return fmt.Errorf("no device code") + } + + if t.HTTP == nil { + t.HTTP = &oauth2.Transport{ + Source: t.Auth.OAuth2Cfg.TokenSource(context.Background()), + } + } + if t.Auth.Token != nil && t.Auth.Token.Valid() { + t.HTTP.Source = oauth2.ReuseTokenSource(t.Auth.Token, t.HTTP.Source) + return nil + } + + for { + if t.Auth == nil || t.Auth.OAuth2Cfg == nil { + return fmt.Errorf("device code revoked") + } + + if time.Since(t.Auth.StartTime).Seconds() > t.Auth.ExpiresIn { + return fmt.Errorf("device code expired") + } + + token, err := t.HTTP.Source.Token() + if err != nil { + time.Sleep(time.Second * time.Duration(t.Auth.Interval)) + continue + } + + t.Auth.Token = token + //t.Auth.UserID = t.Auth.Token.Extra("user").(map[string]interface{})["userId"].(int64) + t.Auth.CountryCode = t.Auth.Token.Extra("user").(map[string]interface{})["countryCode"].(string) + return nil + } +} + +// ClearDeviceCode clears the current device code, nulling everything about the authenticated user +func (t *TidalClient) ClearDeviceCode() { + t.Auth = nil + t.HTTP = nil +} + +// NewTidal returns a new Tidal client for continuous use +func NewTidal(clientID, clientSecret string) (t *TidalClient, err error) { + t = &TidalClient{ + ClientID: clientID, + ClientSecret: clientSecret, + } + + err = t.NewDeviceCode() + return t, err +} + +// NewTidalBlob returns a new Tidal client from a blob file for continuous use +func NewTidalBlob(blobPath string) (t *TidalClient, err error) { + blob, err := os.Open(blobPath) + if err != nil { + return nil, err + } + defer blob.Close() + + blobJSON, err := ioutil.ReadAll(blob) + if err != nil { + return nil, err + } + + err = json.Unmarshal(blobJSON, &t) + if err != nil { + return nil, err + } + + t.HTTP = &oauth2.Transport{ + Source: t.Auth.OAuth2Cfg.TokenSource(context.Background()), + } + if t.Auth.Token != nil && t.Auth.Token.Valid() { + t.HTTP.Source = oauth2.ReuseTokenSource(t.Auth.Token, t.HTTP.Source) + } + + return t, err +} + +// SaveBlob saves a Tidal client to a blob file for later use +func (t *TidalClient) SaveBlob(blobPath string) { + blob, err := os.Create(blobPath) + if err != nil { + Error.Println(err) + return + } + defer blob.Close() + + blobJSON, err := json.Marshal(t, true) + if err != nil { + Error.Println(err) + return + } + + _, _ = blob.Write(blobJSON) +} + +// ReplaceURI replaces all instances of a URI with a libremedia-acceptable URI +func (t *TidalClient) ReplaceURI(text string) string { + return tidalurire.ReplaceAllStringFunc(text, tidalReplaceURI) +} + +func tidalReplaceURI(link string) string { + fmt.Println("Testing string: " + link) + match := tidalurire.FindAllStringSubmatch(link, 1) + if len(match) > 0 { + typed := match[0][1] + id := match[0][2] + name := match[0][3] + switch typed { + case "albumId": + typed = "album" + case "artistId": + typed = "creator" + } + return "[" + name + "](/" + typed + "?uri=tidal:" + typed + ":" + id + ")" + } + return link +}