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 0000000..5845580 Binary files /dev/null and b/favicon.ico differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d59db46 --- /dev/null +++ b/go.mod @@ -0,0 +1,45 @@ +module github.com/JoshuaDoes/libremedia + +go 1.20 + +require ( + github.com/JoshuaDoes/json v0.0.0-20200726213358-ec3860544ac0 + github.com/dsoprea/go-utility v0.0.0-20221003172846-a3e1774ef349 + github.com/eolso/librespot-golang v0.0.0-20230506023304-cdb078f4ea7f + github.com/librespot-org/librespot-golang v0.0.0-20220325184705-31669e5a889f + github.com/rhnvrm/lyric-api-go v0.1.4 + golang.org/x/oauth2 v0.9.0 +) + +require ( + github.com/PuerkitoBio/goquery v1.8.1 // indirect + github.com/andybalholm/cascadia v1.3.2 // indirect + github.com/badfortrains/mdns v0.0.0-20160325001438-447166384f51 // indirect + github.com/brynbellomy/klog v0.0.0-20200414031930-87fbf2e555ae // indirect + github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect + github.com/eolso/threadsafe v0.0.0-20230304165831-d28da4e4d0d3 // indirect + github.com/go-errors/errors v1.4.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/gosimple/slug v1.13.1 // indirect + github.com/gosimple/unidecode v1.0.1 // indirect + github.com/jfbus/httprs v1.0.1 // indirect + github.com/miekg/dns v1.1.55 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect + github.com/rs/cors v1.9.0 // indirect + github.com/xlab/portaudio-go v0.0.0-20170905165025-132d041879db // indirect + github.com/xlab/vorbis-go v0.0.0-20210911202351-b5b85f1ec645 // indirect + golang.org/x/crypto v0.10.0 // indirect + golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect + golang.org/x/mod v0.11.0 // indirect + golang.org/x/net v0.11.0 // indirect + golang.org/x/sync v0.3.0 // indirect + golang.org/x/sys v0.9.0 // indirect + golang.org/x/tools v0.10.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.31.0 // indirect +) + +replace github.com/librespot-org/librespot-golang => ../../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 0000000..f912704 Binary files /dev/null and b/img/flaticon-freepik-singer.png differ diff --git a/img/icons8-download-96.png b/img/icons8-download-96.png new file mode 100644 index 0000000..2e7effa Binary files /dev/null and b/img/icons8-download-96.png differ diff --git a/img/icons8-play-96.png b/img/icons8-play-96.png new file mode 100644 index 0000000..94605c2 Binary files /dev/null and b/img/icons8-play-96.png differ diff --git a/img/loading.gif b/img/loading.gif new file mode 100644 index 0000000..5f752e5 Binary files /dev/null and b/img/loading.gif differ 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 +}