Monorepo for Aesthetic.Computer
aesthetic.computer
1// FF1 Proxy (Netlify Function)
2// Relays FF1 displayPlaylist requests to Feral File cloud relay.
3//
4// NOTE: The Feral File relay (artwork-info.feral-file.workers.dev) may be
5// temporarily unavailable. This function provides two modes:
6// 1. Cloud relay mode (requires topicID + apiKey from FF1 app)
7// 2. Direct mode (requires deviceIp - FF1 must be accessible from server)
8
9const RELAY_BASE = "https://artwork-info.feral-file.workers.dev/api/cast";
10
11function corsHeaders() {
12 return {
13 "Access-Control-Allow-Origin": "*",
14 "Access-Control-Allow-Methods": "POST, OPTIONS",
15 "Access-Control-Allow-Headers": "Content-Type",
16 };
17}
18
19export async function handler(event) {
20 if (event.httpMethod === "OPTIONS") {
21 return {
22 statusCode: 204,
23 headers: corsHeaders(),
24 body: "",
25 };
26 }
27
28 if (event.httpMethod !== "POST") {
29 return {
30 statusCode: 405,
31 headers: corsHeaders(),
32 body: JSON.stringify({ success: false, error: "Method not allowed" }),
33 };
34 }
35
36 let body;
37 try {
38 body = JSON.parse(event.body || "{}");
39 } catch (err) {
40 return {
41 statusCode: 400,
42 headers: corsHeaders(),
43 body: JSON.stringify({ success: false, error: "Invalid JSON body" }),
44 };
45 }
46
47 const { topicID, apiKey, command, request, deviceIp } = body;
48
49 // Mode 1: Direct device connection (if deviceIp provided)
50 if (deviceIp) {
51 const directUrl = `http://${deviceIp}:1111/api/cast`;
52 const payload = {
53 command: command || "displayPlaylist",
54 request,
55 };
56
57 try {
58 const response = await fetch(directUrl, {
59 method: "POST",
60 headers: { "Content-Type": "application/json" },
61 body: JSON.stringify(payload),
62 });
63
64 const responseText = await response.text();
65 let responseData;
66 try {
67 responseData = JSON.parse(responseText);
68 } catch {
69 responseData = { raw: responseText };
70 }
71
72 if (!response.ok) {
73 return {
74 statusCode: response.status,
75 headers: corsHeaders(),
76 body: JSON.stringify({
77 success: false,
78 error: `FF1 direct error: ${response.status}`,
79 details: responseData,
80 }),
81 };
82 }
83
84 return {
85 statusCode: 200,
86 headers: corsHeaders(),
87 body: JSON.stringify({ success: true, response: responseData, mode: "direct" }),
88 };
89 } catch (err) {
90 return {
91 statusCode: 500,
92 headers: corsHeaders(),
93 body: JSON.stringify({
94 success: false,
95 error: `Cannot reach FF1 at ${deviceIp}: ${err.message}`,
96 hint: "FF1 must be accessible from the server (same network or tunneled)"
97 }),
98 };
99 }
100 }
101
102 // Mode 2: Cloud relay (requires topicID)
103 if (!topicID) {
104 return {
105 statusCode: 400,
106 headers: corsHeaders(),
107 body: JSON.stringify({
108 success: false,
109 error: "Missing topicID - get this from your FF1 app settings",
110 hint: "Go to FF1 app > Settings > Developer to find your Topic ID",
111 }),
112 };
113 }
114
115 if (!apiKey) {
116 return {
117 statusCode: 400,
118 headers: corsHeaders(),
119 body: JSON.stringify({
120 success: false,
121 error: "Missing apiKey - the cloud relay requires an API key",
122 hint: "Get your API key from the FF1 app or contact Feral File support",
123 }),
124 };
125 }
126
127 const relayUrl = `${RELAY_BASE}?topicID=${encodeURIComponent(topicID)}`;
128
129 const payload = {
130 command: command || "displayPlaylist",
131 request,
132 };
133
134 try {
135 const response = await fetch(relayUrl, {
136 method: "POST",
137 headers: {
138 "Content-Type": "application/json",
139 "API-KEY": apiKey,
140 },
141 body: JSON.stringify(payload),
142 });
143
144 const responseText = await response.text();
145 let responseData;
146 try {
147 responseData = JSON.parse(responseText);
148 } catch {
149 responseData = { raw: responseText };
150 }
151
152 if (!response.ok) {
153 // Check for Cloudflare worker errors (relay may be down)
154 if (response.status === 404 || responseText.includes("error code: 1042")) {
155 return {
156 statusCode: 503,
157 headers: corsHeaders(),
158 body: JSON.stringify({
159 success: false,
160 error: "FF1 cloud relay appears to be unavailable",
161 hint: "The Feral File relay service may be down. Try using direct mode with deviceIp, or use the local tunnel (ac-ff1 tunnel).",
162 details: responseData,
163 }),
164 };
165 }
166
167 return {
168 statusCode: response.status,
169 headers: corsHeaders(),
170 body: JSON.stringify({
171 success: false,
172 error: `FF1 relay error: ${response.status}`,
173 details: responseData,
174 }),
175 };
176 }
177
178 return {
179 statusCode: 200,
180 headers: corsHeaders(),
181 body: JSON.stringify({ success: true, response: responseData, mode: "relay" }),
182 };
183 } catch (err) {
184 return {
185 statusCode: 500,
186 headers: corsHeaders(),
187 body: JSON.stringify({ success: false, error: err.message }),
188 };
189 }
190}