Add spotify renderer
This commit is contained in:
parent
16fd5b0b95
commit
b55c3a666f
17 changed files with 1464 additions and 22 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -23,3 +23,5 @@ zig-cache/
|
|||
|
||||
.envrc
|
||||
.direnv
|
||||
|
||||
.config/
|
||||
|
|
13
build.zig
13
build.zig
|
@ -94,6 +94,19 @@ pub fn build(b: *std.Build) !void {
|
|||
|
||||
const render_exe_install = b.addInstallArtifact(departures_exe, .{});
|
||||
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);
|
||||
}
|
||||
|
||||
{
|
||||
|
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1 +1,2 @@
|
|||
pub const efa = @import("efa/root.zig");
|
||||
pub const spotify = @import("spotify/root.zig");
|
||||
|
|
221
src/eink_feed_render/api/spotify/models.zig
Normal file
221
src/eink_feed_render/api/spotify/models.zig
Normal 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,
|
||||
};
|
607
src/eink_feed_render/api/spotify/root.zig
Normal file
607
src/eink_feed_render/api/spotify/root.zig
Normal 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,
|
||||
);
|
||||
}
|
||||
};
|
|
@ -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());
|
||||
|
||||
var tmp_file = try temp.TempFile.create(arena.allocator(), .{
|
||||
.pattern = "departures-*.html",
|
||||
.pattern = "render-departures-*.html",
|
||||
});
|
||||
defer tmp_file.deinit();
|
||||
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 });
|
||||
defer file.close();
|
||||
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);
|
||||
|
|
|
@ -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(
|
||||
\\<!DOCTYPE html>
|
||||
\\<html lang="de">
|
||||
|
|
66
src/eink_feed_render/apps/Spotify/root.zig
Normal file
66
src/eink_feed_render/apps/Spotify/root.zig
Normal 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);
|
||||
}
|
194
src/eink_feed_render/apps/Spotify/template/css/fonts.css.dat
Normal file
194
src/eink_feed_render/apps/Spotify/template/css/fonts.css.dat
Normal file
File diff suppressed because one or more lines are too long
47
src/eink_feed_render/apps/Spotify/template/css/style.css
Normal file
47
src/eink_feed_render/apps/Spotify/template/css/style.css
Normal 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;
|
||||
}
|
151
src/eink_feed_render/apps/Spotify/template/root.zig
Normal file
151
src/eink_feed_render/apps/Spotify/template/root.zig
Normal 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(
|
||||
\\,
|
||||
);
|
||||
}
|
||||
}
|
||||
try writer.writeAll(
|
||||
\\ </h2>
|
||||
\\</div>
|
||||
);
|
||||
}
|
||||
|
||||
try writer.writeAll(
|
||||
\\ </div>
|
||||
\\ </body>
|
||||
\\</html>
|
||||
);
|
||||
}
|
|
@ -1 +1,2 @@
|
|||
pub const Departures = @import("Departures/root.zig");
|
||||
pub const Spotify = @import("Spotify/root.zig");
|
||||
|
|
|
@ -33,3 +33,18 @@ pub fn escapeUriComponent(allocator: std.mem.Allocator, data: []const u8) !?[]co
|
|||
|
||||
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;
|
||||
}
|
||||
|
|
126
src/eink_feed_render/main/spotify.zig
Normal file
126
src/eink_feed_render/main/spotify.zig
Normal 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, ¶ms, 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, ¶ms, .{});
|
||||
}
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
16
src/eink_feed_render/result.zig
Normal file
16
src/eink_feed_render/result.zig
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
pub const ResultArena = @import("result.zig").ResultArena;
|
||||
pub const api = @import("api/root.zig");
|
||||
pub const apps = @import("apps/root.zig");
|
||||
pub const renderer = @import("renderer.zig");
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue