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
|
.envrc
|
||||||
.direnv
|
.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, .{});
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
@ -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 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());
|
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);
|
||||||
|
|
|
@ -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">
|
||||||
|
|
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 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;
|
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 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");
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue