folx-tv/server/dropbox.ts
sebastjanartic 4b5b3e5d97 Improve photo gallery with artist names and infinite scrolling
Refactor photo gallery to include artist names extracted from filenames, implement pagination with infinite scrolling, and optimize image loading.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 517dfa7b-26ac-463d-a6e1-a58c6df97188
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 79e0a082-1752-4a36-8483-997b7269c4f3
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/517dfa7b-26ac-463d-a6e1-a58c6df97188/nFw7xof
Replit-Helium-Checkpoint-Created: true
2026-03-02 16:39:45 +00:00

274 lines
7.5 KiB
TypeScript

import fs from "fs";
import path from "path";
const TOKEN_PATH = path.join(process.cwd(), "server/dropbox-token.json");
const GALLERY_ROOT = "/family room/photos/oddaje/izvajalci selekcija";
interface DropboxTokens {
access_token: string;
refresh_token: string;
expires_at: number;
app_key?: string;
app_secret?: string;
}
interface GalleryImage {
folder: string;
fileName: string;
thumb: string;
large: string;
full: string;
artist: string;
}
function extractArtistFromFileName(fileName: string): string {
if (/^DSC\d/i.test(fileName) || /^IMG[\s_]\d/i.test(fileName)) return "";
const withoutExt = fileName.replace(/\.\w+$/, "");
const cleaned = withoutExt
.replace(/\s*\(\d+\)\s*$/, "")
.replace(/\s*\d+\s*$/, "")
.replace(/\s*\(\d+\)\s*$/, "");
return cleaned.trim();
}
function getAppKey(): string {
const tokens = loadTokens();
return tokens?.app_key || process.env.DROPBOX_APP_KEY || "";
}
function getAppSecret(): string {
const tokens = loadTokens();
return tokens?.app_secret || process.env.DROPBOX_APP_SECRET || "";
}
export function getAuthUrl(redirectUri: string): string {
const params = new URLSearchParams({
client_id: getAppKey(),
response_type: "code",
redirect_uri: redirectUri,
token_access_type: "offline",
});
return `https://www.dropbox.com/oauth2/authorize?${params.toString()}`;
}
export async function exchangeCodeForTokens(code: string, redirectUri: string): Promise<DropboxTokens> {
const appKey = getAppKey();
const appSecret = getAppSecret();
const resp = await fetch("https://api.dropboxapi.com/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
client_id: appKey,
client_secret: appSecret,
}),
});
if (!resp.ok) {
const err = await resp.text();
throw new Error(`Token exchange failed: ${err}`);
}
const data = await resp.json();
const tokens: DropboxTokens = {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: Date.now() + data.expires_in * 1000,
app_key: appKey,
app_secret: appSecret,
};
fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2));
return tokens;
}
function loadTokens(): DropboxTokens | null {
try {
if (fs.existsSync(TOKEN_PATH)) {
return JSON.parse(fs.readFileSync(TOKEN_PATH, "utf-8"));
}
} catch {}
return null;
}
async function refreshAccessToken(refreshToken: string): Promise<DropboxTokens> {
const appKey = getAppKey();
const appSecret = getAppSecret();
const resp = await fetch("https://api.dropboxapi.com/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: appKey,
client_secret: appSecret,
}),
});
if (!resp.ok) {
const err = await resp.text();
throw new Error(`Token refresh failed: ${err}`);
}
const data = await resp.json();
const tokens: DropboxTokens = {
access_token: data.access_token,
refresh_token: refreshToken,
expires_at: Date.now() + data.expires_in * 1000,
app_key: appKey,
app_secret: appSecret,
};
fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2));
return tokens;
}
export async function getValidAccessToken(): Promise<string | null> {
let tokens = loadTokens();
if (!tokens) return null;
if (Date.now() > tokens.expires_at - 60000) {
try {
tokens = await refreshAccessToken(tokens.refresh_token);
} catch (err: any) {
console.error("Token refresh failed:", err.message);
return null;
}
}
return tokens.access_token;
}
export function isConnected(): boolean {
return loadTokens() !== null;
}
async function listFolder(accessToken: string, folderPath: string, recursive = false): Promise<any[]> {
const entries: any[] = [];
let cursor: string | null = null;
let hasMore = true;
while (hasMore) {
const url = cursor
? "https://api.dropboxapi.com/2/files/list_folder/continue"
: "https://api.dropboxapi.com/2/files/list_folder";
const body = cursor
? { cursor }
: { path: folderPath, recursive, limit: 2000 };
const resp = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.text();
throw new Error(`List folder failed: ${err}`);
}
const data = await resp.json();
entries.push(...data.entries);
hasMore = data.has_more;
cursor = data.cursor;
}
return entries;
}
async function getTemporaryLinks(accessToken: string, paths: string[]): Promise<Map<string, string>> {
const results = new Map<string, string>();
const batchSize = 10;
for (let i = 0; i < paths.length; i += batchSize) {
const batch = paths.slice(i, i + batchSize);
const promises = batch.map(async (p) => {
try {
const resp = await fetch("https://api.dropboxapi.com/2/files/get_temporary_link", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ path: p }),
});
if (resp.ok) {
const data = await resp.json();
results.set(p.toLowerCase(), data.link);
}
} catch {}
});
await Promise.all(promises);
}
return results;
}
const galleryCache: { data: GalleryImage[]; timestamp: number } = { data: [], timestamp: 0 };
const CACHE_DURATION = 30 * 60 * 1000;
export async function fetchGalleryFromDropbox(): Promise<GalleryImage[]> {
if (galleryCache.data.length > 0 && Date.now() - galleryCache.timestamp < CACHE_DURATION) {
return galleryCache.data;
}
const accessToken = await getValidAccessToken();
if (!accessToken) return [];
try {
const topEntries = await listFolder(accessToken, GALLERY_ROOT);
const folders = topEntries.filter((e: any) => e[".tag"] === "folder");
const images: GalleryImage[] = [];
for (const folder of folders) {
try {
const folderName = folder.name;
const folderEntries = await listFolder(accessToken, folder.path_lower);
const imageFiles = folderEntries.filter(
(e: any) => e[".tag"] === "file" && /\.(jpg|jpeg|png|webp|gif)$/i.test(e.name)
);
const paths = imageFiles.map((f: any) => f.path_lower);
const tempLinks = await getTemporaryLinks(accessToken, paths);
for (const file of imageFiles) {
const link = tempLinks.get(file.path_lower) || "";
if (link) {
images.push({
folder: folderName,
fileName: file.name,
thumb: link,
large: link,
full: link,
artist: extractArtistFromFileName(file.name),
});
}
}
} catch (err: any) {
console.error(`Folder ${folder.name} error:`, err.message);
}
}
if (images.length > 0) {
galleryCache.data = images;
galleryCache.timestamp = Date.now();
const galleryPath = path.join(process.cwd(), "server/gallery-data.json");
fs.writeFileSync(galleryPath, JSON.stringify(images, null, 2));
}
return images;
} catch (err: any) {
console.error("Dropbox gallery fetch error:", err.message);
return [];
}
}