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 @@ + + +
+>>=w=b>>>24,p-=w,!(16&(w=b>>>16&255))){if(0==(64&w)){b=_[(65535&b)+(c&(1< ' + smallBioView + ' ... ' + smallBioHidden + '";
+
+//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, '
' + 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 += ' ';
+ }
+ html += '';
+ if (creator.object.description != null && creator.object.description.length > 0) {
+ const bio = creator.object.description
+ .replace(/\r\n|\r|\n/gim, '
';
+ }
+ html += '
') // linebreaks
+ .replace(/\[([^\[]+)\](\(([^)]*))\)/gim, '$1'); // anchor tags
+ const splitBio = bio.split(" ");
+ 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 += '';
+ if (splitBio.length > 20) {
+ smallBioView = splitBio.slice(0, 20).join(" ");
+ smallBioHidden = smallBioView + " " + splitBio.slice(20, splitBio.length).join(" ");
+ html += '
(tap to show more)
(tap again to show less) ';
+ }
+ html += ' ';
+
+ //Discs
+ for (let i = 0; i < album.object.discs.length; i++) {
+ html += 'Disc ' + (i + 1) + ' ';
+ 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 += 'Streams (' + album.object.discs[i].streams.length + ') 🕑
';
+ }
+
+ 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++;
+ html += '';
+ colspan++;
+ }
+ if (colspan > 0) {
+ 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 += ' ';
+ } 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 = '
';
+ if (stream.creators != null) {
+ html += '';
+ } else {
+ html += refresh;
+ }
+ html += '
';
+ if (stream.album != null) {
+ html += '';
+ } else {
+ html += refresh;
+ }
+ html += '' + secondsTimestamp(stream.duration) + ' ';
+
+ 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 = ' '
+ 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 += 'Streams 🕑 ';
+
+ 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 = ' ';
+ 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 = 'Creators ';
+ 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 = 'Related
';
+ if (stream.album != null) {
+ html += '';
+ } else {
+ html += refresh;
+ }
+ html += '' + secondsTimestamp(stream.duration) + ' ';
+
+ 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 = ' ';
+ 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 += 'Top Streams 🕑 ';
+
+ 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 = ' ';
+ 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 = 'Albums ';
+ 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 = 'Singles ';
+ 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 = 'Appears On ';
+ } else {
+ html += refresh + '';
+ }
+ html += ' ' + secondsTimestamp(stream.duration) + ' ';
+
+ 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
+}