Add spotify renderer

This commit is contained in:
Dominic Grimm 2025-06-21 21:09:08 +02:00
parent 16fd5b0b95
commit b55c3a666f
Signed by: dergrimm
SSH key fingerprint: SHA256:0uoWpcqOtkyvQ+ZqBjNYiDqIZY+9s8VeZkkJ/4ryB4E
17 changed files with 1464 additions and 22 deletions

2
.gitignore vendored
View file

@ -23,3 +23,5 @@ zig-cache/
.envrc .envrc
.direnv .direnv
.config/

View file

@ -94,6 +94,19 @@ pub fn build(b: *std.Build) !void {
const render_exe_install = b.addInstallArtifact(departures_exe, .{}); const render_exe_install = b.addInstallArtifact(departures_exe, .{});
render_step.dependOn(&render_exe_install.step); render_step.dependOn(&render_exe_install.step);
const spotify_exe = b.addExecutable(.{
.name = "eink-feed-render-spotify",
.root_source_file = b.path("src/eink_feed_render/main/spotify.zig"),
.target = target,
.optimize = optimize,
});
spotify_exe.root_module.addImport("eink_feed_render", render_mod);
spotify_exe.root_module.addImport("clap", clap_dep.module("clap"));
spotify_exe.linkLibC();
const spotify_exe_install = b.addInstallArtifact(spotify_exe, .{});
render_step.dependOn(&spotify_exe_install.step);
} }
{ {

View file

@ -1,19 +0,0 @@
{
"channels": [
{
"id": 1,
"name": "test-channel",
"display": {
"width": 800,
"height": 600,
"orientation": "landscape_left"
}
}
],
"clients": [
{
"name": "test-kindle",
"channel_id": 1
}
]
}

View file

@ -1 +1,2 @@
pub const efa = @import("efa/root.zig"); pub const efa = @import("efa/root.zig");
pub const spotify = @import("spotify/root.zig");

View file

@ -0,0 +1,221 @@
const std = @import("std");
pub const Device = struct {
id: ?[]const u8 = null,
is_active: bool,
is_private_session: bool,
is_restricted: bool,
name: []const u8,
type: []const u8,
volume_percent: ?u7 = null,
supports_volume: bool,
};
pub const RepeatState = enum {
off,
track,
context,
};
pub const ExternalUrls = struct {
spotify: []const u8,
};
pub const Context = struct {
type: []const u8,
href: []const u8,
external_urls: ExternalUrls,
};
pub const CurrentlyPlayingType = enum {
track,
episode,
ad,
unknown,
};
pub const Image = struct {
url: []const u8,
height: ?u32 = null,
width: ?u32 = null,
};
pub const Restrictions = struct {
pub const Reason = enum {
market,
product,
explicit,
};
reason: Reason,
};
pub const SimplifiedArtist = struct {
external_urls: ExternalUrls,
href: []const u8,
id: []const u8,
name: []const u8,
type: enum { artist },
uri: []const u8,
};
pub const Album = struct {
pub const Type = enum {
album,
single,
compilation,
};
album_type: Type,
total_tracks: u32,
available_markets: ?[]const []const u8 = null,
external_urls: ExternalUrls,
href: []const u8,
id: []const u8,
images: []const Image,
name: []const u8,
release_date: []const u8,
release_date_precision: []const u8,
restrictions: ?Restrictions = null,
type: enum { album },
uri: []const u8,
artists: []const SimplifiedArtist,
};
pub const ExternalIds = struct {
isc: ?[]const u8 = null,
ean: ?[]const u8 = null,
upc: ?[]const u8 = null,
};
pub const Track = struct {
album: Album,
artists: []const SimplifiedArtist,
available_markets: ?[]const []const u8 = null,
disc_number: u32,
duration_ms: u32,
explicit: bool,
external_ids: ExternalIds,
external_urls: ExternalUrls,
href: []const u8,
id: []const u8,
is_playable: bool,
restrictions: ?Restrictions = null,
name: []const u8,
popularity: u8,
track_number: u32,
type: enum { track },
uri: []const u8,
is_local: bool,
};
pub const Episode = struct {
// description: []const u8,
// html_description: []const u8,
// duration_ms: u32,
// explicit: bool,
// external_urls: ExternalUrls,
// href: []const u8,
// id: []const u8,
// images: []const Image,
// is_externally_hosted: bool,
// languages: []const []const u8,
// name: []const u8,
// release_date: []const u8,
// release_date_precision: []const u8,
// type: enum { episode },
// uri: []const u8,
// restrictions: ?Restrictions = null,
// show: struct {},
};
pub const GetPlaybackStateResponse = struct {
pub const Item = struct {
value: union(enum) {
track: Track,
episode: Episode,
},
pub fn jsonStringify(self: *const Item, jws: anytype) !void {
return switch (self.value) {
inline else => |v| jws.write(v),
};
}
pub fn jsonParse(
allocator: std.mem.Allocator,
source: anytype,
options: std.json.ParseOptions,
) std.json.ParseError(@TypeOf(source.*))!Item {
const value = try std.json.innerParse(std.json.Value, allocator, source, options);
return jsonParseFromValue(allocator, value, options);
}
pub fn jsonParseFromValue(
allocator: std.mem.Allocator,
value: std.json.Value,
options: std.json.ParseOptions,
) std.json.ParseFromValueError!Item {
const Type = enum { track, episode };
switch (value) {
.object => |v| {
const item_type: Type = switch (v.get("type") orelse return error.UnexpectedToken) {
.string => |s| try std.json.parseFromValueLeaky(
Type,
allocator,
.{ .string = s },
options,
),
else => return error.UnexpectedToken,
};
switch (item_type) {
.track => {
const res = try std.json.parseFromValueLeaky(
Track,
allocator,
.{ .object = v },
options,
);
return .{ .value = .{ .track = res } };
},
.episode => {
const res = try std.json.parseFromValueLeaky(
Episode,
allocator,
.{ .object = v },
options,
);
return .{ .value = .{ .episode = res } };
},
}
},
else => return error.UnexpectedToken,
}
}
};
device: Device,
repeat_state: RepeatState,
shuffle_state: bool,
context: ?Context = null,
timestamp: i128,
progress_ms: ?u64 = null,
is_playing: bool,
item: ?Item = null,
currently_playing_type: CurrentlyPlayingType,
actions: struct {
interrupting_playback: ?bool = null,
pausing: ?bool = null,
resuming: ?bool = null,
seeking: ?bool = null,
skipping_next: ?bool = null,
skipping_prev: ?bool = null,
toggling_repeat_context: ?bool = null,
toggling_shuffle: ?bool = null,
toggling_repeat_track: ?bool = null,
transferring_playback: ?bool = null,
},
smart_shuffle: ?bool = null,
};

View file

@ -0,0 +1,607 @@
const std = @import("std");
const escaper = @import("../../escaper.zig");
const ResultArena = @import("../../result.zig").ResultArena;
pub const models = @import("models.zig");
pub const Client = struct {
const log = std.log.scoped(.spotify_client);
const Self = @This();
const HeaderBufSize: usize = 4096;
const BodyLimit: usize = 10 * 1024 * 1024;
const PkceCodeVerifierLen: usize = 128;
const accounts_url_str = "https://accounts.spotify.com";
const base_auth_url_str = std.fmt.comptimePrint("{s}/authorize", .{accounts_url_str});
const token_url_str = std.fmt.comptimePrint("{s}/api/token", .{accounts_url_str});
const token_url = std.Uri.parse(token_url_str) catch unreachable;
const api_url_str = "https://api.spotify.com/v1";
const base_get_playback_state_url_str = std.fmt.comptimePrint("{s}/me/player", .{api_url_str});
const auth_scopes = &[_][]const u8{
"user-read-playback-state",
"user-modify-playback-state",
};
const auth_scopes_str: []const u8 = blk: {
var len: usize = 0;
for (auth_scopes, 0..) |s, i| {
len += s.len;
if (i < auth_scopes.len - 1)
len += 1;
}
var buf: [len]u8 = undefined;
var ch_i: usize = 0;
for (auth_scopes, 0..) |s, i| {
@memcpy(buf[ch_i .. ch_i + s.len], s);
ch_i += s.len;
if (i < auth_scopes.len - 1) {
buf[ch_i] = ' ';
ch_i += 1;
}
}
const final = buf;
break :blk &final;
};
pub const AuthStore = struct {
arena: *std.heap.ArenaAllocator,
access_token: []const u8,
authorization: []const u8,
creation_time: std.time.Instant,
expires_in: u64,
refresh_token: []const u8,
fn deinit(self: *AuthStore) void {
self.arena.deinit();
self.arena.child_allocator.destroy(self.arena);
}
fn genAuthorization(allocator: std.mem.Allocator, token: []const u8) ![]const u8 {
return try std.fmt.allocPrint(allocator, "Bearer {s}", .{token});
}
};
pub const Config = struct {
client_id: []const u8,
market: []const u8,
callback_server_host: []const u8,
callback_server_port: u16,
auth_store_file_path: []const u8,
};
allocator: std.mem.Allocator,
auth_scope_escaped: ?[]const u8,
client_id: []const u8,
client_id_escaped: ?[]const u8,
callback_server_host: []const u8,
callback_server_port: u16,
redirect_uri: []const u8,
redirect_uri_escaped: ?[]const u8,
market: []const u8,
market_escaped: ?[]const u8,
auth_store: ?*AuthStore,
auth_store_file_path: []const u8,
pub fn init(
allocator: std.mem.Allocator,
config: *const Config,
) !Self {
const auth_scope_escaped = try escaper.escapeUriComponent(allocator, Self.auth_scopes_str);
errdefer if (auth_scope_escaped) |s| allocator.free(s);
const client_id_escaped = try escaper.escapeUriComponent(allocator, config.client_id);
errdefer if (client_id_escaped) |s| allocator.free(s);
const redirect_uri = try std.fmt.allocPrint(
allocator,
"http://{s}:{d}",
.{ config.callback_server_host, config.callback_server_port },
);
errdefer allocator.free(redirect_uri);
const redirect_uri_escaped = try escaper.escapeUriComponent(allocator, redirect_uri);
errdefer if (redirect_uri_escaped) |s| allocator.free(s);
const market_escaped = try escaper.escapeUriComponent(allocator, config.market);
errdefer if (market_escaped) |s| allocator.free(s);
return .{
.allocator = allocator,
.auth_scope_escaped = auth_scope_escaped,
.client_id = config.client_id,
.client_id_escaped = client_id_escaped,
.callback_server_host = config.callback_server_host,
.callback_server_port = config.callback_server_port,
.redirect_uri = redirect_uri,
.redirect_uri_escaped = redirect_uri_escaped,
.market = config.market,
.auth_store = null,
.auth_store_file_path = config.auth_store_file_path,
.market_escaped = market_escaped,
};
}
pub fn deinit(self: *Self) void {
if (self.auth_scope_escaped) |s| self.allocator.free(s);
if (self.client_id_escaped) |s| self.allocator.free(s);
self.allocator.free(self.redirect_uri);
if (self.redirect_uri_escaped) |s| self.allocator.free(s);
if (self.market_escaped) |s| self.allocator.free(s);
if (self.auth_store) |store| {
store.deinit();
self.allocator.destroy(store);
}
}
fn getAuth(self: *const Self) *const AuthStore {
return self.auth_store.?;
}
pub fn saveAuth(self: *const Self) !void {
const auth = self.getAuth();
const file = try std.fs.cwd().createFile(self.auth_store_file_path, .{});
defer file.close();
try file.writeAll(auth.refresh_token);
}
pub fn restoreAuth(self: *Self) !?void {
std.fs.cwd().access(self.auth_store_file_path, .{}) catch |err| switch (err) {
error.FileNotFound => return null,
else => |e| return e,
};
const file = try std.fs.cwd().openFile(self.auth_store_file_path, .{});
defer file.close();
var buf: [512]u8 = undefined;
const n = try file.readAll(&buf);
const refresh_token = std.mem.trim(u8, buf[0..n], &.{ ' ', '\t', '\r', '\n' });
try self.refreshToken(refresh_token);
}
fn escapedAuthScope(self: *const Self) []const u8 {
return self.auth_scope_escaped orelse Self.auth_scopes_str;
}
fn escapedClientId(self: *const Self) []const u8 {
return self.client_id_escaped orelse self.client_id;
}
fn escapedRedirectUri(self: *const Self) []const u8 {
return self.redirect_uri_escaped orelse self.redirect_uri;
}
fn escapedMarket(self: *const Self) []const u8 {
return self.market_escaped orelse self.market;
}
fn parseQueryParameters(allocator: std.mem.Allocator, target: []const u8) (error{InvalidUri} || std.mem.Allocator.Error)!std.StringHashMap([]const u8) {
var map = std.StringHashMap([]const u8).init(allocator);
errdefer map.deinit();
var query_section_started = false;
var capture_start: usize = undefined;
var param_name: ?[]const u8 = null;
for (target, 0..) |ch, i| {
if (ch == '?') {
query_section_started = true;
capture_start = i + 1;
continue;
}
if (!query_section_started)
continue;
if (i == capture_start)
continue;
if (ch == '=') {
if (param_name != null)
return error.InvalidUri;
param_name = target[capture_start..i];
capture_start = i + 1;
continue;
}
if (ch == '&') {
if (param_name == null)
return error.InvalidUri;
try map.put(param_name.?, target[capture_start..i]);
capture_start = i + 1;
param_name = null;
continue;
}
}
if (param_name != null) {
try map.put(param_name.?, target[capture_start..]);
}
return map;
}
fn authCallbackServer(allocator: std.mem.Allocator, host: []const u8, port: u16) !?[]const u8 {
log.info("Opening auth callback server: {s}:{d}", .{ host, port });
const address = try std.net.Address.parseIp(host, port);
const tpe: u32 = std.posix.SOCK.STREAM;
const protocol = std.posix.IPPROTO.TCP;
const listener = try std.posix.socket(address.any.family, tpe, protocol);
defer std.posix.close(listener);
try std.posix.setsockopt(
listener,
std.posix.SOL.SOCKET,
std.posix.SO.REUSEADDR,
&std.mem.toBytes(@as(c_int, 1)),
);
try std.posix.bind(listener, &address.any, address.getOsSockLen());
try std.posix.listen(listener, 1);
const stream = std.net.Stream{ .handle = listener };
var server = std.net.Server{
.listen_address = address,
.stream = stream,
};
log.info("Waiting for connection", .{});
const conn = try server.accept();
var buf: [4096]u8 = undefined;
var http_server = std.http.Server.init(conn, &buf);
var request = try http_server.receiveHead();
log.info("Accepted connection: {} {s}", .{ request.head.method, request.head.target });
var param_map = try Self.parseQueryParameters(allocator, request.head.target);
defer param_map.deinit();
const code_param_raw = param_map.get("code") orelse return null;
const code_param_unescaped = try escaper.unescapeUriComponent(allocator, code_param_raw);
const code_copy = try allocator.dupe(u8, code_param_unescaped orelse code_param_raw);
errdefer allocator.free(code_copy);
try request.respond(
"<center><h1>Authorization successful!</h1></center>",
.{
.keep_alive = false,
.extra_headers = &[_]std.http.Header{
.{
.name = "Content-Type",
.value = "text/html;charset=utf-8",
},
},
},
);
return code_copy;
}
pub const AuthError = error{
SpotifyAuthFailed,
};
fn pkceCodeVerifier(comptime len: usize, random: std.Random) [len]u8 {
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
var buf: [len]u8 = undefined;
for (0..len) |i| {
const ch_i = random.uintLessThan(usize, charset.len);
buf[i] = charset[ch_i];
}
return buf;
}
fn pkceCodeChallenge(verifier: []const u8) [std.base64.url_safe_no_pad.Encoder.calcSize(std.crypto.hash.sha2.Sha256.digest_length)]u8 {
var sha = std.crypto.hash.sha2.Sha256.init(.{});
sha.update(verifier);
const digest = sha.finalResult();
const encoder = std.base64.url_safe_no_pad.Encoder;
var challenge: [std.base64.url_safe_no_pad.Encoder.calcSize(std.crypto.hash.sha2.Sha256.digest_length)]u8 = undefined;
_ = encoder.encode(&challenge, &digest);
return challenge;
}
const AuthTokenResponse = struct {
access_token: []const u8,
token_type: []const u8,
expires_in: u64,
refresh_token: []const u8,
scope: []const u8,
};
fn handleAuthTokenResponse(self: *Self, body: []const u8) !*const AuthStore {
if (self.auth_store) |store| {
store.deinit();
self.allocator.destroy(store);
}
self.auth_store = null;
const response = try std.json.parseFromSlice(
AuthTokenResponse,
self.allocator,
body,
.{ .ignore_unknown_fields = true },
);
defer response.deinit();
if (!std.mem.eql(u8, response.value.token_type, "Bearer"))
return AuthError.SpotifyAuthFailed;
var arena = try self.allocator.create(std.heap.ArenaAllocator);
errdefer self.allocator.destroy(arena);
arena.* = std.heap.ArenaAllocator.init(self.allocator);
errdefer arena.deinit();
const auth_store = try self.allocator.create(AuthStore);
errdefer self.allocator.destroy(auth_store);
auth_store.* = .{
.arena = arena,
.access_token = try arena.allocator().dupe(u8, response.value.access_token),
.authorization = try AuthStore.genAuthorization(arena.allocator(), response.value.access_token),
.creation_time = try std.time.Instant.now(),
.expires_in = response.value.expires_in,
.refresh_token = try arena.allocator().dupe(u8, response.value.refresh_token),
};
self.auth_store = auth_store;
try self.saveAuth();
return auth_store;
}
pub fn refreshToken(self: *Self, refresh_token: []const u8) !void {
const refresh_token_escaped = try escaper.escapeUriComponent(self.allocator, refresh_token);
defer if (refresh_token_escaped) |s| self.allocator.free(s);
const payload = try std.fmt.allocPrint(
self.allocator,
"grant_type=refresh_token&refresh_token={[refresh_token]s}&client_id={[client_id]s}",
.{
.refresh_token = refresh_token_escaped orelse refresh_token,
.client_id = self.escapedClientId(),
},
);
defer self.allocator.free(payload);
var client = std.http.Client{ .allocator = self.allocator };
defer client.deinit();
var buf: [HeaderBufSize]u8 = undefined;
var req = try client.open(.POST, Self.token_url, .{
.server_header_buffer = &buf,
.headers = .{
.content_type = .{ .override = "application/x-www-form-urlencoded" },
},
});
defer req.deinit();
req.transfer_encoding = .{ .content_length = payload.len };
try req.send();
try req.writeAll(payload);
try req.finish();
try req.wait();
if (req.response.status != .ok)
return AuthError.SpotifyAuthFailed;
const body = try req.reader().readAllAlloc(self.allocator, Self.BodyLimit);
defer self.allocator.free(body);
_ = try self.handleAuthTokenResponse(body);
}
pub fn authorize(self: *Self) !void {
if (self.auth_store) |store| {
store.deinit();
self.allocator.destroy(store);
}
self.auth_store = null;
const random = std.crypto.random;
const code_verifier_raw = pkceCodeVerifier(Self.PkceCodeVerifierLen, random);
const code_verifier_escaped = try escaper.escapeUriComponent(self.allocator, &code_verifier_raw);
defer if (code_verifier_escaped) |s| self.allocator.free(s);
const code_verifier = code_verifier_escaped orelse &code_verifier_raw;
const code_challenge_raw = Self.pkceCodeChallenge(&code_verifier_raw);
const code_challenge_escaped = try escaper.escapeUriComponent(self.allocator, &code_verifier_raw);
defer if (code_challenge_escaped) |s| self.allocator.free(s);
const code_challenge = code_challenge_escaped orelse &code_challenge_raw;
const auth_url_str = try std.fmt.allocPrint(
self.allocator,
"{[base]s}?response_type=code&client_id={[client_id]s}&scope={[scope]s}&code_challenge_method=S256&code_challenge={[code_challenge]s}&redirect_uri={[redirect_uri]s}",
.{
.base = Self.base_auth_url_str,
.client_id = self.escapedClientId(),
.scope = self.escapedAuthScope(),
.code_challenge = code_challenge,
.redirect_uri = self.escapedRedirectUri(),
},
);
defer self.allocator.free(auth_url_str);
log.info("auth url: {s}", .{auth_url_str});
const maybe_code = try Self.authCallbackServer(
self.allocator,
self.callback_server_host,
self.callback_server_port,
);
defer if (maybe_code) |s| self.allocator.free(s);
if (maybe_code == null) return AuthError.SpotifyAuthFailed;
const code_raw = maybe_code.?;
const code_escaped = try escaper.escapeUriComponent(self.allocator, code_raw);
defer if (code_escaped) |s| self.allocator.free(s);
const code = code_escaped orelse code_raw;
const payload = try std.fmt.allocPrint(
self.allocator,
"grant_type=authorization_code&client_id={[client_id]s}&redirect_uri={[redirect_uri]s}&code_verifier={[code_verifier]s}&code={[code]s}",
.{
.client_id = self.escapedClientId(),
.redirect_uri = self.escapedRedirectUri(),
.code_verifier = code_verifier,
.code = code,
},
);
defer self.allocator.free(payload);
var client = std.http.Client{ .allocator = self.allocator };
defer client.deinit();
var buf: [HeaderBufSize]u8 = undefined;
var req = try client.open(.POST, Self.token_url, .{
.server_header_buffer = &buf,
.headers = .{
.content_type = .{ .override = "application/x-www-form-urlencoded" },
},
});
defer req.deinit();
req.transfer_encoding = .{ .content_length = payload.len };
try req.send();
try req.writeAll(payload);
try req.finish();
try req.wait();
if (req.response.status != .ok)
return AuthError.SpotifyAuthFailed;
const body = try req.reader().readAllAlloc(self.allocator, Self.BodyLimit);
defer self.allocator.free(body);
_ = try self.handleAuthTokenResponse(body);
}
pub fn checkAccessTokenExpired(self: *const Self) !?bool {
if (self.auth_store) |store| {
const now = try std.time.Instant.now();
const dt: f32 = @as(f32, @floatFromInt(now.since(store.creation_time))) / @as(f32, 1e9);
return dt >= @as(f32, @floatFromInt(store.expires_in));
} else {
return null;
}
}
pub const ApiError = error{
Unauthorized,
TooManyRequests,
UnexpectedResponse,
};
fn handleResponse(
self: *const Self,
comptime T: type,
req: *std.http.Client.Request,
expected_status: std.http.Status,
) !ResultArena(std.json.Parsed(T)) {
var result = ResultArena(std.json.Parsed(models.GetPlaybackStateResponse)){
.arena = try self.allocator.create(std.heap.ArenaAllocator),
.value = undefined,
};
errdefer self.allocator.destroy(result.arena);
result.arena.* = std.heap.ArenaAllocator.init(self.allocator);
errdefer result.arena.deinit();
switch (req.response.status) {
.unauthorized => return ApiError.Unauthorized,
.too_many_requests => return ApiError.TooManyRequests,
else => |code| if (code != expected_status) {
log.err("Unexptected response: {}", .{code});
return ApiError.UnexpectedResponse;
},
}
const body = try req.reader().readAllAlloc(result.arena.allocator(), Self.BodyLimit);
errdefer result.arena.allocator().free(body);
const response = try std.json.parseFromSlice(
models.GetPlaybackStateResponse,
result.arena.allocator(),
body,
.{ .ignore_unknown_fields = true },
);
errdefer response.deinit();
result.value = response;
return result;
}
fn sendRequest(
self: *Self,
comptime T: type,
method: std.http.Method,
expected_status: std.http.Status,
url_str: []const u8,
payload: ?[]const u8,
) !ResultArena(std.json.Parsed(T)) {
const url = try std.Uri.parse(url_str);
if ((try self.checkAccessTokenExpired()).?)
try self.refreshToken(self.getAuth().refresh_token);
const auth = self.getAuth();
var client = std.http.Client{ .allocator = self.allocator };
defer client.deinit();
var buf: [HeaderBufSize]u8 = undefined;
var req = try client.open(method, url, .{
.server_header_buffer = &buf,
.headers = .{
.authorization = .{ .override = auth.authorization },
},
});
defer req.deinit();
if (payload) |s| {
req.transfer_encoding = .{ .content_length = s.len };
}
try req.send();
if (payload) |s| try req.writeAll(s);
try req.finish();
try req.wait();
return try self.handleResponse(T, &req, expected_status);
}
pub fn getPlaybackState(self: *Self) !ResultArena(std.json.Parsed(models.GetPlaybackStateResponse)) {
const url_str = try std.fmt.allocPrint(
self.allocator,
"{[base]s}?market={[market]s}",
.{
.base = Self.base_get_playback_state_url_str,
.market = self.escapedMarket(),
},
);
defer self.allocator.free(url_str);
return try self.sendRequest(
models.GetPlaybackStateResponse,
.GET,
.ok,
url_str,
null,
);
}
};

View file

@ -116,7 +116,7 @@ pub fn render(self: *const Self, efa_dm_resp: *const api.efa.models.DmResponse,
const escaped_context = try context.escape(arena.allocator()); const escaped_context = try context.escape(arena.allocator());
var tmp_file = try temp.TempFile.create(arena.allocator(), .{ var tmp_file = try temp.TempFile.create(arena.allocator(), .{
.pattern = "departures-*.html", .pattern = "render-departures-*.html",
}); });
defer tmp_file.deinit(); defer tmp_file.deinit();
const path = try tmp_file.parent_dir.realpathAlloc(arena.allocator(), tmp_file.basename); const path = try tmp_file.parent_dir.realpathAlloc(arena.allocator(), tmp_file.basename);
@ -124,7 +124,7 @@ pub fn render(self: *const Self, efa_dm_resp: *const api.efa.models.DmResponse,
const file = try tmp_file.open(.{ .mode = .write_only }); const file = try tmp_file.open(.{ .mode = .write_only });
defer file.close(); defer file.close();
const writer = file.writer(); const writer = file.writer();
try template.render(&escaped_context, writer); try template.render(&escaped_context, writer.any());
} }
return try renderer.render(self.allocator, path, self.dimensions); return try renderer.render(self.allocator, path, self.dimensions);

View file

@ -111,7 +111,7 @@ pub const Context = struct {
} }
}; };
pub fn render(context: *const Context, writer: anytype) !void { pub fn render(context: *const Context, writer: std.io.AnyWriter) !void {
try writer.writeAll( try writer.writeAll(
\\<!DOCTYPE html> \\<!DOCTYPE html>
\\<html lang="de"> \\<html lang="de">

View file

@ -0,0 +1,66 @@
const std = @import("std");
const temp = @import("temp");
const Dimensions = @import("../../Dimensions.zig");
const renderer = @import("../../renderer.zig");
pub const api = @import("../../api/root.zig");
const template = @import("template/root.zig");
const Self = @This();
allocator: std.mem.Allocator,
dimensions: Dimensions,
pub fn init(allocator: std.mem.Allocator, dimensions: Dimensions) Self {
return .{
.allocator = allocator,
.dimensions = dimensions,
};
}
pub fn render(self: *const Self, response: *const api.spotify.models.GetPlaybackStateResponse) ![]const u8 {
var arena = std.heap.ArenaAllocator.init(self.allocator);
defer arena.deinit();
var context = template.Context{ .title = null };
if (response.item) |item| {
switch (item.value) {
.track => |track| {
const artists = try arena.allocator().alloc([]const u8, track.artists.len);
for (track.artists, 0..) |artist, i| {
artists[i] = artist.name;
}
context.title = .{
.title_name = track.name,
.artists = artists,
.album_name = if (track.album.album_type != .single) track.album.name else null,
.album_cover_url = track.album.images[0].url,
};
},
.episode => {},
}
}
const escaped_context = try context.escape(arena.allocator());
var tmp_file = try temp.TempFile.create(arena.allocator(), .{
.pattern = "render-spotify-*.html",
});
defer tmp_file.deinit();
const path = try tmp_file.parent_dir.realpathAlloc(arena.allocator(), tmp_file.basename);
{
const file = try tmp_file.open(.{ .mode = .write_only });
defer file.close();
const writer = file.writer();
try template.render(
self.dimensions,
&escaped_context.value,
writer.any(),
);
}
return try renderer.render(self.allocator, path, self.dimensions);
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,47 @@
html,
body {
margin: 0;
padding: 0;
font-family: "Noto Sans", sans-serif;
font-optical-sizing: auto;
font-weight: bolder;
font-style: normal;
font-variation-settings: "wdth" 100;
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.75rem;
}
#wrapper {
width: 100%;
height: 100%;
color: black;
}
#album-cover {
width: 100%;
height: auto;
display: block;
box-shadow:
rgba(0, 0, 0, 0.19) 0px 10px 20px,
rgba(0, 0, 0, 0.23) 0px 6px 6px;
}
#title {
display: block;
width: 90%;
margin-left: auto;
margin-right: auto;
text-align: center;
white-space: nowrap;
overflow: hidden;
}
#title > #album-name {
font-style: italic;
}

View file

@ -0,0 +1,151 @@
const std = @import("std");
const ResultArena = @import("../../../result.zig").ResultArena;
const Dimensions = @import("../../../Dimensions.zig");
const escaper = @import("../../../escaper.zig");
const api = @import("../../../api/root.zig");
pub const Title = struct {
title_name: []const u8,
artists: []const []const u8,
album_name: ?[]const u8,
album_cover_url: ?[]const u8,
pub fn escape(self: *const Title, allocator: std.mem.Allocator) !ResultArena(Title) {
var result = ResultArena(Title){
.arena = try allocator.create(std.heap.ArenaAllocator),
.value = undefined,
};
errdefer allocator.destroy(result.arena);
result.arena.* = std.heap.ArenaAllocator.init(allocator);
errdefer result.arena.deinit();
const title_name = try escaper.escapeHtml(result.arena.allocator(), self.title_name);
result.value.title_name = title_name orelse self.title_name;
const artists = try result.arena.allocator().alloc([]const u8, self.artists.len);
for (self.artists, 0..) |artist, i| {
const escaped = try escaper.escapeHtml(result.arena.allocator(), artist);
artists[i] = escaped orelse artist;
}
result.value.artists = artists;
result.value.album_name = null;
if (self.album_name) |str| {
const album_name = try escaper.escapeHtml(result.arena.allocator(), str);
result.value.album_name = album_name orelse str;
}
result.value.album_cover_url = null;
if (self.album_cover_url) |str| {
const album_cover_url = try escaper.escapeHtml(result.arena.allocator(), str);
result.value.album_cover_url = album_cover_url orelse str;
}
return result;
}
};
pub const Context = struct {
title: ?Title,
pub fn escape(self: *const Context, allocator: std.mem.Allocator) !ResultArena(Context) {
var result = ResultArena(Context){
.arena = try allocator.create(std.heap.ArenaAllocator),
.value = undefined,
};
errdefer allocator.destroy(result.arena);
result.arena.* = std.heap.ArenaAllocator.init(allocator);
errdefer result.arena.deinit();
result.value.title = null;
if (self.title) |title| {
const escaped = try title.escape(result.arena.allocator());
errdefer escaped.deinit();
result.value.title = escaped.value;
}
return result;
}
};
pub fn render(dimensions: Dimensions, context: *const Context, writer: std.io.AnyWriter) !void {
try writer.writeAll(
\\<!doctype html>
\\<html lang="en">
\\ <head>
\\ <meta charset="UTF-8" />
);
try writer.print(
\\<meta
\\ name="viewport"
\\ content="width={[width]d}, height={[height]d}, initial-scale=1.0, user-scalable=no"
\\/>
,
.{ .width = dimensions.width, .height = dimensions.height },
);
try writer.print(
\\<style>{[fonts_css]s}</style>
\\<style>{[style_css]s}</style>
\\<style>
\\ html,
\\ body {{
\\ width: {[width]d}px;
\\ height: {[height]d}px;
\\ }}
\\</style>
,
.{
.fonts_css = @embedFile("css/fonts.css.dat"),
.style_css = @embedFile("css/style.css"),
.width = dimensions.width,
.height = dimensions.height,
},
);
try writer.writeAll(
\\</head>
);
try writer.writeAll(
\\<body>
\\ <div id="wrapper">
);
if (context.title) |title| {
try writer.print(
\\<img id="album-cover" src="{[album_cover_url]s}" />
\\<div id="title">
\\ <h1 id="title-name">{[title_name]s}</h1>
\\ <h2 id="album-name">{[album_name]s}</h2>
\\ <h2 id="artists">
,
.{
.album_cover_url = title.album_cover_url orelse "",
.title_name = title.title_name,
.album_name = title.album_name orelse "",
},
);
for (title.artists, 0..) |artist, i| {
try writer.print(
\\<span>{s}</span>
,
.{artist},
);
if (i < title.artists.len - 1) {
try writer.writeAll(
\\,&nbsp;
);
}
}
try writer.writeAll(
\\ </h2>
\\</div>
);
}
try writer.writeAll(
\\ </div>
\\ </body>
\\</html>
);
}

View file

@ -1 +1,2 @@
pub const Departures = @import("Departures/root.zig"); pub const Departures = @import("Departures/root.zig");
pub const Spotify = @import("Spotify/root.zig");

View file

@ -33,3 +33,18 @@ pub fn escapeUriComponent(allocator: std.mem.Allocator, data: []const u8) !?[]co
return escaped; return escaped;
} }
pub fn unescapeUriComponent(allocator: std.mem.Allocator, data: []const u8) !?[]const u8 {
var buf: c.gh_buf = undefined;
c.gh_buf_init(&buf, data.len);
defer c.gh_buf_free(&buf);
const res = c.houdini_unescape_uri_component(&buf, @ptrCast(data), data.len);
if (res == 0) return null;
const unescaped = try allocator.alloc(u8, c.gh_buf_len(&buf));
errdefer allocator.free(unescaped);
@memcpy(unescaped, c.gh_buf_cstr(&buf)[0..unescaped.len]);
return unescaped;
}

View file

@ -0,0 +1,126 @@
const std = @import("std");
const builtin = @import("builtin");
const clap = @import("clap");
const render = @import("eink_feed_render");
pub const std_options: std.Options = .{
.log_level = if (builtin.mode == .Debug) .debug else .info,
};
pub const ConfigFile = struct {
client: struct {
id: []const u8,
// secret: []const u8,
auth_callback: struct {
host: []const u8,
port: u16,
},
},
market: []const u8,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{
.thread_safe = true,
}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
{
const params = comptime clap.parseParamsComptime(
\\-h, --help Display this help and exit.
\\--url <str> URL of eink-feed server.
\\--channel <u32> Channel ID.
\\--config <str> Path to config file.
\\--auth <str> Path to authorization store file.
);
var diag = clap.Diagnostic{};
var res = clap.parse(clap.Help, &params, clap.parsers.default, .{
.diagnostic = &diag,
.allocator = allocator,
}) catch |err| {
diag.report(std.io.getStdErr().writer(), err) catch {};
return err;
};
defer res.deinit();
if (res.args.help != 0 or
res.args.config == null or
res.args.auth == null or
res.args.url == null or
res.args.channel == null)
{
const writer = std.io.getStdErr().writer();
try writer.writeAll("eink-feed-render-spotify\n");
return clap.help(writer, clap.Help, &params, .{});
}
var config_file: std.json.Parsed(ConfigFile) = undefined;
{
const data = try std.fs.cwd().readFileAlloc(allocator, res.args.config.?, 1 * 1024 * 1024);
defer allocator.free(data);
config_file = try std.json.parseFromSlice(
ConfigFile,
allocator,
data,
.{ .allocate = .alloc_always, .ignore_unknown_fields = true },
);
}
defer config_file.deinit();
render.renderer.init();
defer render.renderer.deinit();
const feed_client = try render.FeedClient.init(allocator, res.args.url.?);
defer feed_client.deinit();
const channel = try feed_client.getChannel(res.args.channel.?);
defer channel.deinit();
const dim = render.Dimensions{
.width = channel.value.display.width,
.height = channel.value.display.height,
};
const orientation = channel.value.display.orientation.toInternal();
const spotify_app = render.apps.Spotify.init(allocator, dim);
var spotify = try render.api.spotify.Client.init(
allocator,
&.{
.client_id = config_file.value.client.id,
.callback_server_host = config_file.value.client.auth_callback.host,
.callback_server_port = config_file.value.client.auth_callback.port,
.market = config_file.value.market,
.auth_store_file_path = res.args.auth.?,
},
);
defer spotify.deinit();
if (try spotify.restoreAuth() == null) {
std.fs.cwd().deleteFile(spotify.auth_store_file_path) catch |err| switch (err) {
error.FileNotFound => {},
else => |e| return e,
};
_ = spotify.authorize() catch |err| {
std.fs.cwd().deleteFile(spotify.auth_store_file_path) catch |e| switch (e) {
error.FileNotFound => {},
else => |e_inner| return e_inner,
};
return err;
};
}
const playback_result = try spotify.getPlaybackState();
defer playback_result.deinit();
const png = try spotify_app.render(&playback_result.value.value);
defer allocator.free(png);
try feed_client.uploadFrame(
channel.value.id,
orientation,
png,
"image/png",
);
}
}

View file

@ -0,0 +1,16 @@
const std = @import("std");
pub fn ResultArena(comptime T: type) type {
return struct {
const Self = @This();
arena: *std.heap.ArenaAllocator,
value: T,
pub fn deinit(self: Self) void {
const allocator = self.arena.child_allocator;
self.arena.deinit();
allocator.destroy(self.arena);
}
};
}

View file

@ -1,3 +1,4 @@
pub const ResultArena = @import("result.zig").ResultArena;
pub const api = @import("api/root.zig"); pub const api = @import("api/root.zig");
pub const apps = @import("apps/root.zig"); pub const apps = @import("apps/root.zig");
pub const renderer = @import("renderer.zig"); pub const renderer = @import("renderer.zig");