~aleteoryx/lfm_embed

ref: release-0.2.0 lfm_embed/README.md -rw-r--r-- 14.6 KiB
f548d2d2alyx Various fixes 7 months ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
# `lfm_embed`
A simple webserver for rendering a last.fm embed.

More specifically, this displays a simple webpage with info about your current or most recent last.fm scrobble.
Its intended use is to be put in an iframe on a personal site or whatever.
While it is self-hostable, there is an official public instance at [scrobble.observer](https://scrobble.observer).

Release builds available [here](https://aleteoryx.me/downloads2/lfm_embed).

Index:

- [Usage](#usage)
- [Self-Hosting](#self-hosting)
- [Configuration](#configuration)
- [Theming](#theming)
- [Contributing](#contributing)
- [Legal](#legal)

# Usage

`lfm_embed` serves one thing:
Requests to `/user/<last.fm username>` will generate a page displaying your current or last last.fm scrobble.
The look and feel of this is down to the configured theme for your request.

As the user, you may select a theme supported by the server by passing `?theme=` query string.
Check with your host for a list of supported themes.

Individual themes may also support parameters, via additional query parameters.

## Builtin Themes

### `plain`

A minimal theme, displaying just the username, album art, and track info.
It is the default fallback for an unset or unknown theme.

![A screenshot of the plain theme, displaying album art, and the text "@vvinrg is scrobbling Sweet Tooth from Sleepyhead by Cavetown".](screenshots/plain-light.png)

#### Parameters

- `?dark`: toggle the theme's dark mode.

# Self-Hosting
`lfm_embed` is, as it stands, designed to use a reverse proxy.
A future release may include HTTPS support, but currently, it will only listen on `localhost`, prohibiting it from public access.
Once configured, a request of the form `http://localhost:9999/user/<last.fm username>` will render out an embed with the default theme.

As it stands, there are no plans to support displaying users with private listen history.

# Configuration
Configuration should be done via the environment.
`lfm_embed` also supports reading from a standard `.env` file.
The following are the environment variables which `lfm_embed` understands.

## Core Config

### `LMFE_LASTFM_API_KEY` (Required)
Your last.fm API key. You'll need to create one [here](https://www.last.fm/api/account/create) for self-hosting.

### `LMFE_GOOGLE_API_KEY`
Your google API key. This is necessary for custom google fonts to work. You can create one [here](https://developers.google.com/fonts/docs/developer_api#APIKey), and you'll need some familiarity with the Google cloud API console

### `LFME_PORT` (Default: `9999`)
The port to serve on locally.

### `LFME_NO_REFRESH` (Default: `0`)
If set to `1`, disable outputting of the HTTP `Refresh: <cache refresh>` header, used to provide live status updates.

## Logging

### `LFME_LOG_LEVEL` (Default: `"warn"`)
The loglevel. This is actually parsed as an [`env_logger`](https://docs.rs/env_logger/latest/env_logger) filter string.
Read the docs for more info.

### `LFME_LOG_FILE`
If set, logs will be written to the specified file. Otherwise, logs are written to `stderr`.

## User Perms

### `LFME_ALLOWLIST_MODE` (Default: `"open"`)
The following(case-insensitive) values are supported:

- `open`: Allow requests for all users.

- `exclusive`: Only allow requests for users in `LFME_ALLOWLIST`, returning HTTP 403 for all others. `LFME_ALLOWLIST` _must_ be set if this mode is enabled.

If the user requested has their listen history private, a 403 will be returned.

### `LFME_ALLOWLIST` (Default: `""`)
This is expected to be a sequence of comma-separated usernames.
Leading/trailing whitespace will be stripped, and unicode is fully supported.

### `LFME_ALLOWLIST_REFRESH` (Default: `"1m"`)
The amount of time to cache allowlisted user info for.
It is interpreted as a sequence of `<num><suffix>` values, where `num` is a positive integer,
and `suffix` is one of `y`,`mon`,`w`,`d`,`h`,`m`,`s`, `ms`, `µs`, or `ns`, each of which
corresponds to a self-explanatory unit of time.
For most practical applications, one should only use `m` and `s`, as caching your current listen for more than that time has the potential to omit most songs.
Parsing is delegated to the [`duration_str`](https://docs.rs/duration-str/latest/duration_str/) crate, and further info may be found there.

### `LFME_DEFAULT_REFRESH` (Default: `"5m"`)
The amount of time to cache non-allowlisted user info for.
See `LFME_ALLOWLIST_REFRESH` for more info.

## Themes

### `LFME_THEME_DIR`
If set, must be a valid path to a directory containing a tree of [Handlebars](https://handlebarsjs.com/guide/#language-features) and [Lua](https://www.lua.org/) files, ending in `LFME_THEME_EXT_HBS` and `LFME_THEME_EXT_LUA`, respectively.
They will be registered as themes on top of the builtin ones, with each theme's name being their filename minus the extension.
Same-named themes will override builtin ones.

See [Theming](#theming) for details on writing custom themes.

Theme names are the same as their path, minus the extension. A directory like:
```
themes/
  mytheme.hbs
  myothertheme.lua
  myunrelatedfile.css
  alices-themes/
    mytheme.lua
    mysuperawesometheme.hbs
```
results in the following themes:
```
mytheme
myothertheme
alices-themes/mytheme
alices-themes/mysuperawesometheme
```

By default, these are loaded and compiled once, at startup.

### `LFME_THEME_EXT_HBS` (Default: `.hbs`)
The file extension for handlebars themes in `LFME_THEME_DIR`.

Note: This behaves more like a suffix than a file extension. You must include the leading dot for a file extension.

### `LFME_THEME_EXT_LUA` (Default: `.lua`)
The file extension for lua themes in `LFME_THEME_DIR`.

Note: This behaves more like a suffix than a file extension. You must include the leading dot for a file extension.

### `LFME_THEME_DEV` (Default: `0`)
If set to `1`, themes will be reloaded on edit.

Note: Even with this mode, adding a new handlebars theme requires a full reload.
Handlebars hemes are only enumerated once, at startup. (This is a limitation of the [`handlebars`](https://docs.rs/handlebars/latest/handlebars) implementation in use, and may change.)

### `LFME_THEME_DEFAULT` (Default: `"plain"`)
The theme to use when no query string is present.

## Example Configuration
```ini
LFME_API_KEY=0123456789abcdef0123456789abcdef
LFME_LOG_LEVEL=error
LFME_PORT=3000

LFME_ALLOWLIST_REFRESH=30s
LFME_ALLOWLIST_MODE=exclusive
LFME_ALLOWLIST=a_precious_basket_case, realRiversCuomo, Pixiesfan12345
```

# Theming

Custom themes are, as stated above, [Handlebars](https://handlebarsjs.com/guide/#language-features) and [Lua](https://www.lua.org/) files.
They are expected to produce a complete HTML doc, which should contain info about a user's scrobbles.

You should have a passing familiarity with either language before reading below.

## Handlebars Themes

Handlebars is a simple templating language, and great for themes that don't do much complicated.
The builtin `plain` theme is written in handlebars, and any themes that want to just relay the basic scrobble info should be written in it.

If you're looking to do more complicated effects, or need server-side computation, see below on writing [Lua Themes](#lua-themes).
Handlebars should __not__ be used if you're looking to do advanced effects.

All builtin handlebars themes are located at [`/src/theming/hbs`](https://git.aleteoryx.me/cgit/lfm_embed/tree/src/theming/hbs).

The following [helpers](https://handlebarsjs.com/guide/expressions.html#helpers) are provided, on top of the handlebars [builtins](https://handlebarsjs.com/guide/builtin-helpers.html):

- `(eq Object Object) => Boolean`: Check equality between its args.

- `(ne Object Object) => Boolean`: Check inequality between its args.

- `(gt Number|String Number|String) => Boolean`: Check if the first arg is greater than the second.

- `(gte Number|String Number|String) => Boolean`: Check if the first arg is greater than or equal to the second.

- `(lt Number|String Number|String) => Boolean`: Check if the first arg is less than the second.

- `(lte Number|String Number|String) => Boolean`: Check if the first arg is less than or equal to the second.

- `(and Boolean Boolean) => Boolean`: Boolean AND gate.

- `(or Boolean Boolean) => Boolean`: Boolean OR gate.

- `(not Boolean) => Boolean`: Boolean NOT gate.

- `(uri_encode String) => String`: URI-encode input text, making the output suitable to be included as part of a link or other URL.

### Writing a Handlebars Theme

Handlebars themes should have, roughly, the skeleton below:

```html
<!DOCTYPE html>
<html lang="en"> <!-- Or really whatever you want, but I speak English. -->
    <head>
        <meta charset="UTF-8">
        <style>
          {{#if font.css}}
            {{{font.css}}}
          {{/if}}
          /* cursed hack to deal with weird rendering in chrome */
          * { font-size: {{#if font}}17px{{else}}{{#if font.scale}}calc(20px * {{font.scale}}){{else}}20px{{/if}}{{/if}}; }
          {{#if font.name }}
            * { font-family: '{{font.name}}' }
          {{/if}}
          /* actual styles */
        </style>
    </head>
    <body>
        {{#if error}}<p>{{error}}</p>{{else}}
            <!-- theme contents here -->
        {{/if}}
    </body>
</html>
```

See below for the [Context Object](#the-context-object)

## Lua Themes

Lua is a powerful scripting language, for themes that need to do a lot of math on the server.
`lfm_embed` uses the [Luau](https://luau-lang.org/) implementation, specicially.

You are provided with only the `table`, `string`, `utf8`, `bit`, and `math` stdlib modules.
You are also provided with the `html` global.

- `html(el: String, tbl: Table) => String`: Generates the HTML for `<el>`, with the sequential entries in `tbl` as concatenated text, and the named entries as attributes. Sequential entries wrapped in a table will be escaped.

- `html.<el>(tbl: Table) => String` Functions like `html`, but with the index name as the element type. It is intended to be called like `html.a{"Homepage", href = "/"}`.

- `html.root(tbl: Table) => String` Generates the root of an HTML document, including doctype, processing `tbl` as normal.

Example:

```lua
print(html.root{html.body{html.p{"Check out my cool site!"}}})
-- <!DOCTYPE html>
-- <html><body><p>Check out my cool site!</p></body></html>

print(html.p{ "This <b>doesn't</b> get escaped, ", {"but this <b>does</b>!"} })
-- <p>This <b>doesn't</b> get escaped, but this &lt;b&gt;does&lt;/b&gt;!</p>
```

### Writing a Lua Theme

Lua theme scripts are expected to return a function which takes one argument, the [Context Object](#the-context-object), and return a fully formed html document.
They may contain anything as far as locals go, but are not allowed to manipulate the global context.
The returned function will be repeatedly called for any incoming request.

Lua themes should have, roughly, the skeleton below:

```lua
local stylefmt = [[
/* font css */
%s
* { font-size: %spx; }
%s
/* actual styles go here*/
]]

return function(ctx)
  local contents
  local style
  if ctx.error then
    contents = html.p{{ctx.error}}
    style = ""
  else
    contents = ""
    if not ctx.font then ctx.font = {} end
    local font_setter = ""
    if ctx.font.name then
      font_setter = string.format("* { font-family: '%s'; }", ctx.font.name)
    end
    local font_px
    -- hack to deal with chrome being weird
    if ctx.font then font_px = 17
    elseif ctx.font.scale then font_px = 20 * ctx.font.scale
    else font_px = 20 end
    style = string.format(stylefmt, ctx.font.css or "", font_px, font_setter)
  end
  return html.root{lang = "en",
    html.head{
      html.meta{charset = "UTF-8"},
      html.style{style}
    },
    html.body{contents}
  }
end
```

## The Context Object

You will be passed one of 2 context objects, depending on if there was an error dealing with the remote backends.
If there was an error, the object will just be `{ error: String }`, otherwise it will be the following:

```js
{
    // The user the request was made on the behalf of.
    user: {
        // Their username.
        name: String,
        // Their "Display Name".
        realname: String,
        // Link to user's profile picture.
        image_url: String,
        // Link to user's profile.
        url: String,

        // Total scrobbles.
        scrobble_count: Number,
        // Number of artists in library.
        artist_count: Number,
        // Number of tracks in library.
        track_count: Number,
        // Number of albums in library.
        album_count: Number,

        // True if user subscribes to last.fm pro.
        pro_subscriber: Boolean
    },

    // The user's most current, or most scrobble.
    scrobble: {
        // The name of the track.
        name: String,
        // The name of its album.
        album: String,
        // The artist who made it.
        artist: {
            // Their name.
            name: String,
            // Link to their profile image.
            image_url: String,
            // Link to the artist's last.fm page.
            url: String
        },
        // A link to the track image.
        image_url: String,
        // A link to the track's last.fm page.
        url: String,

        // True if the user has loved the track.
        loved: Boolean,
        // True if the user is currently scrobbling it, false if it's just
        // the most recently played track.
        now_playing: Boolean
    },

    // Custom font info.
    font: {
        // Set if the theme should replace the custom font with something.
        name: String?,
        // Will contain CSS which includes a custom font as 'included_font'.
        css: String?,
        // Will contain a factor of the base font scale to scale the theme's font by, for custom font reasons.
        scale: Number?
    },

    // A set of extraneous query parameters.
    //
    // This should be considered UNTRUSTED, as the requester has full
    // control over it. Use the provided `html_escape`,
    // `html_attr_escape`, and `uri_encode` helpers when inlining
    // any contained text.
    query: Object
}
```

# Contributing

[E-Mail me](mailto:alyx@aleteoryx.me) if you'd like to help out, submit a custom theme, or request a feature.

# Legal
```txt
    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program.  If not, see <https://www.gnu.org/licenses/>.
```