Skip to content

Commit

Permalink
New app: Now Spinning (#2806)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielsitnik authored Oct 16, 2024
1 parent ede7332 commit 9da50b2
Show file tree
Hide file tree
Showing 3 changed files with 388 additions and 0 deletions.
47 changes: 47 additions & 0 deletions apps/nowspinning/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Now Spinning

## Overview

This app was developed by request for the user `Diesel7688` on the [Tidbyt Forums](https://discuss.tidbyt.com/t/now-spinning/6964).

It displays the album cover, album name and artist name from a specific album chosen by the user.

The app itself is not connected to any music service and does not update automatically. The user needs to manually change the displayed album. This was also by request.

---

## Configuration (Schema)

The app has a `Typeahead` control where the user types the name of the album and it displays a list of options to choose from. This list is populated with results from a Spotify API, see details below.

There are also options to change the app colors and one option to hide the app from rotation.

---

## API Details

As mentioned above, the app uses Spotify's [Search for Item](https://developer.spotify.com/documentation/web-api/reference/search) API to build a list of possible albums to choose from.

### Authentication

The API above requires bearer token authentication. To do this, we follow the same process as Spotify's own public web player. We call an open endpoint that returns an access token, which is then used on the next request.

The token is cached for its validity and the cover images are cached for 24 hours.

### Rate Limiting

Spotify's APIs are [rate limited](https://developer.spotify.com/documentation/web-api/concepts/rate-limits), however the value is not published on the documentation. The only information is that the limit is calculated based on a 30 second rolling window.

---

## Error Handling

The app has safeguards in place to identify potential errors and always display something on the screen. For instance, failure to recover the cover image of an album will be handled and a default image will be shown.

Failures when calling the API that populates the `Typeahead` control are also handled and a message is shown on the Tidbyt screen.

---

## Future Improvements

This app is already pretty straightforward and there is nothing much else to add. Trying to connect it to a music service is pointless because then it will behave like the official apps like Spotify or Sonos.
6 changes: 6 additions & 0 deletions apps/nowspinning/manifest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
id: now-spinning
name: Now Spinning
summary: Showcase your music
desc: Displays the name and cover of an artist's album. Not connected to any music service, you need to manually change the album. Type the album name to view available options, include the artist's name to help refine results.
author: Daniel Sitnik
335 changes: 335 additions & 0 deletions apps/nowspinning/now_spinning.star
Original file line number Diff line number Diff line change
@@ -0,0 +1,335 @@
"""
Applet: Now Spinning
Summary: Showcase your music
Description: Displays the name and cover of an artist's album. Not connected to any music service, you need to manually change the album. Type the album name to view available options, include the artist's name to help refine results.
Author: Daniel Sitnik
"""

load("encoding/base64.star", "base64")
load("encoding/json.star", "json")
load("http.star", "http")
load("humanize.star", "humanize")
load("render.star", "render")
load("schema.star", "schema")

RECORD_ICON = base64.decode("""
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
""")

DEFAULT_ALBUM = json.encode({"display": "none", "value": "none"})
DEFAULT_FONT_NAME = "tb-8"
DEFAULT_HEADER_COLOR = "#1db954"
DEFAULT_ALBUM_COLOR = "#e833f2"
DEFAULT_ARTIST_COLOR = "#ffffff"
DEFAULT_HIDE_APP = False

DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"

TOKEN_CACHE_TTL = 3120 # 52 minutes
COVER_CACHE_TTL = 86400 # 1 day

DEBUG = False

def main(config):
"""Main app method.
Args:
config (config): App configuration.
Returns:
widget: Root widget tree.
"""

font_name = config.str("font_name", DEFAULT_FONT_NAME)
header_color = config.str("header_color", DEFAULT_HEADER_COLOR)
album_color = config.str("album_color", DEFAULT_ALBUM_COLOR)
artist_color = config.str("artist_color", DEFAULT_ARTIST_COLOR)
album = json.decode(config.get("album", DEFAULT_ALBUM))
hide_app = config.bool("hide_app", DEFAULT_HIDE_APP)
dprint(album)

# hides the app
if hide_app:
return []

# if user has not selected an album yet, render default view
if album["value"] == "none":
return render_app(RECORD_ICON, "Select", "#fff", "album!", "#fff", header_color, DEFAULT_FONT_NAME)

# if there was an error, render default view
if album["value"] == "error":
return render_app(RECORD_ICON, "Error", "#f00", "try again", "#ff0", header_color, DEFAULT_FONT_NAME)

# split album, artist and cover url from config value
album_name = album["value"].split("|")[0]
artist_name = album["value"].split("|")[1]
cover_url = album["value"].split("|")[2]
dprint("{} by {} ({})".format(album_name, artist_name, cover_url))

# get cover
cover = get_cover(cover_url)

# check if there was an error getting the cover
if cover == None:
cover = RECORD_ICON

return render_app(cover, album_name, album_color, artist_name, artist_color, header_color, font_name)

def get_cover(cover_url):
"""Retrieves the cover image for an album.
Args:
cover_url (str): URL of the cover image.
Returns:
blob: Retrieved image content or None if not found/error.
"""

# if there was no cover, return the default spinning record icon
if cover_url == "none":
return RECORD_ICON

# fetch cover from url
dprint("Getting cover: %s" % cover_url)
res = http.get(cover_url, ttl_seconds = COVER_CACHE_TTL, headers = {
"User-Agent": DEFAULT_USER_AGENT,
})

# if error, return default spinning record icon
if res.status_code != 200:
print("Error getting cover, status %d" % res.status_code)
dprint(res.body())
return RECORD_ICON

return res.body()

def render_header(header_color):
"""Renders the app header widgets.
Args:
header_color (str): The hex color for the header text.
Returns:
widget: Widgets to render the app header.
"""

return render.Box(
width = 64,
height = 6,
child = render.Text("now spinning", font = "tom-thumb", color = header_color),
)

def render_app(cover, album, album_color, artist, artist_color, header_color, font_name):
"""Renders the app widget structure.
Args:
cover (blob): The album's cover art.
album (str): The album's name.
album_color (str): The hex color for the album name.
artist (str): The artist' name.
artist_color (str): The hex color for the artist name.
header_color (str): The hex color for the app header.
Returns:
widget: Root widget structure.
"""

return render.Root(
delay = 80,
child = render.Column(
children = [
render_header(header_color),
render.Box(width = 64, height = 1, color = "#fff"),
render.Padding(
pad = 1,
child = render.Row(
children = [
render.Image(height = 23, src = cover),
render.Padding(
pad = (1, 0, 0, 0),
child = render.Column(
children = [
render.Marquee(
width = 39,
child = render.Text(album, color = album_color, font = font_name),
),
render.Marquee(
width = 39,
child = render.Text(artist, color = artist_color, font = font_name),
),
],
),
),
],
),
),
],
),
)

def get_schema():
"""Setup the schema for the configuration screen.
Returns:
schema: Schema for the configuration screen.
"""

return schema.Schema(
version = "1",
fields = [
schema.Typeahead(
id = "album",
name = "Album",
desc = "Name of the album (add artist to refine).",
icon = "compactDisc",
handler = album_search,
),
schema.Dropdown(
id = "font_name",
name = "Text size",
desc = "Size of the album and artist names.",
icon = "font",
default = DEFAULT_FONT_NAME,
options = [
schema.Option(display = "Small", value = "tom-thumb"),
schema.Option(display = "Medium", value = DEFAULT_FONT_NAME),
schema.Option(display = "Large", value = "Dina_r400-6"),
],
),
schema.Color(
id = "header_color",
name = "Header color",
desc = "Color of the app name.",
icon = "brush",
default = DEFAULT_HEADER_COLOR,
),
schema.Color(
id = "album_color",
name = "Album color",
desc = "Color of the album name.",
icon = "brush",
default = DEFAULT_ALBUM_COLOR,
),
schema.Color(
id = "artist_color",
name = "Artist color",
desc = "Color of the artist name.",
icon = "brush",
default = DEFAULT_ARTIST_COLOR,
),
schema.Toggle(
id = "hide_app",
name = "Hide app",
desc = "Removes the app from your rotation.",
icon = "toggleOff",
default = DEFAULT_HIDE_APP,
),
],
)

def album_search(album_name):
"""Searches for albums based on a name.
Args:
album_name (str): The album name to search.
Returns:
schema.Option[]: List of album options for the user to pick.
"""

# authenticate with spotify to be able to use search api

# fake field to signal error to the user
fake_error_field = schema.Option(display = "ERROR: Please close this screen and try adding the app again.", value = "error")

res = http.get(
"https://open.spotify.com/get_access_token?reason=transport&productType=web_player",
ttl_seconds = TOKEN_CACHE_TTL,
headers = {
"User-Agent": DEFAULT_USER_AGENT,
},
)

if res.status_code != 200:
print("API error {}: {}".format(str(res.status_code), res.body()))

# return the fake field to signal error to the user
return [fake_error_field]

# get auth data
data = res.json()

# get token
access_token = data["accessToken"]
dprint("Access token: %s" % access_token)

# strip spaces
stripped_name = album_name.strip()

# we need at least 3 characters to proceed
if len(stripped_name) < 3:
return []

# build url
url = "https://api.spotify.com/v1/search?q={}&type=album&limit=30&offset=0".format(humanize.url_encode(stripped_name))
dprint("Calling %s" % url)
res = http.get(url, headers = {
"Authorization": "Bearer %s" % access_token,
"User-Agent": DEFAULT_USER_AGENT,
})
dprint("Response: %d" % res.status_code)

if res.status_code != 200:
print("API Error {}: {}".format(res.status_code, res.body()))

# return the fake field to signal error to the user
return [fake_error_field]

# get data
data = res.json()

if not "albums" in data:
dprint("albums field not returned")
return []

if not "items" in data["albums"]:
dprint("albums.items field not returned")
return []

if len(data["albums"]["items"]) == 0:
dprint("albums.items is empty")
return []

dprint("Found %d albums" % len(data["albums"]["items"]))

options = []
for release in data["albums"]["items"]:
title = release["name"]
type = release["type"].capitalize()
artist = release["artists"][0]["name"]
date = release.get("release_date", "0000")[0:4]
cover_url = get_cover_url(release)
options.append(schema.Option(
display = "{} by {} ({}, {})".format(title, artist, type, date),
value = "|".join([title, artist, cover_url]), # concatenate album|artist|cover
))

return options

def get_cover_url(release):
images = release.get("images", [])

if len(images) == 0:
return "none"

# get last image as it's usually the smaller size
return images[len(images) - 1]["url"]

def dprint(message):
"""Prints messages when in debug mode.
Args:
message (str): The message to print.
"""
if DEBUG:
print(message)

0 comments on commit 9da50b2

Please sign in to comment.