sonophage_stardust-importer/main.ts
Sonophage 45bd08d5a1 fix: address Obsidian plugin reviewer findings
- Move modal code styling to styles.css; use Modal.setTitle() for the heading
- Replace raw <h3> settings headings with Setting().setHeading()
- Use activeDocument instead of document for popout-window compatibility
- Use window.setTimeout instead of bare setTimeout
- Type Last.fm/Trakt API responses and Templater access; remove all `any`
- Drop unnecessary regex escape in sanitizeTitle
- Add tag-triggered release workflow that attaches main.js/manifest.json/styles.css

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:14:27 -07:00

247 lines
9 KiB
TypeScript

import { App, Modal, Notice, Plugin, TFile } from "obsidian";
import { DEFAULT_SETTINGS, StardustImporterSettings } from "./src/settings";
import { StardustImporterSettingTab } from "./src/settings-tab";
import { TraktClient, DeviceCode } from "./src/trakt";
import { LastfmClient } from "./src/lastfm";
import { detectAlbumListens } from "./src/sessions";
import { NoteWriter, sanitizeTitle, TemplatePayload } from "./src/notes";
export default class StardustImporter extends Plugin {
settings!: StardustImporterSettings;
// Payloads handed to Templater, keyed by note basename (== tp.file.title).
pending: Record<string, TemplatePayload> = {};
trakt!: TraktClient;
lastfm!: LastfmClient;
notes!: NoteWriter;
private startupTimer: number | null = null;
private running = false;
async onload(): Promise<void> {
await this.loadSettings();
this.applyMediaKeys();
this.trakt = new TraktClient(this);
this.lastfm = new LastfmClient(this);
this.notes = new NoteWriter(this);
this.addSettingTab(new StardustImporterSettingTab(this.app, this));
this.addCommand({ id: "pull-now", name: "Pull now (incremental)", callback: () => this.runPull() });
this.addCommand({ id: "backfill-trakt", name: "Backfill Trakt movies (one-time)", callback: () => this.backfillTrakt() });
this.addCommand({ id: "connect-trakt", name: "Connect Trakt (device auth)", callback: () => this.connectTrakt() });
// Delayed incremental pull once the workspace is ready.
this.app.workspace.onLayoutReady(() => {
const delay = Math.max(this.settings.startupDelaySec, 0) * 1000;
this.startupTimer = window.setTimeout(() => void this.runPull(), delay);
});
}
onunload(): void {
if (this.startupTimer !== null) window.clearTimeout(this.startupTimer);
activeDocument.body.style.removeProperty("--tmdb-api-key");
activeDocument.body.style.removeProperty("--steamgriddb-api-key");
}
/**
* Publish the media keys as CSS custom properties on <body>. The media cards
* (custom-views) and the Movie/Show/Video Game Templater templates read them
* via getComputedStyle(document.body).getPropertyValue("--tmdb-api-key").
* Call on load and whenever the keys change in settings.
*/
applyMediaKeys(): void {
const { enabled, mediaKeys } = this.settings;
activeDocument.body.style.setProperty("--tmdb-api-key", enabled.tmdb ? mediaKeys.tmdb : "");
activeDocument.body.style.setProperty("--steamgriddb-api-key", enabled.steamgriddb ? mediaKeys.steamgriddb : "");
}
async loadSettings(): Promise<void> {
const data = ((await this.loadData()) as Record<string, unknown> | null) ?? {};
// Migrate the pre-release field name: referencesFolder → mediaFolder.
// Preserves an existing user's configured destination across the rename.
if (data.referencesFolder && !data.mediaFolder) {
data.mediaFolder = data.referencesFolder;
delete data.referencesFolder;
}
this.settings = Object.assign({}, DEFAULT_SETTINGS, data);
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
// ---- Orchestration ----------------------------------------------------
async runPull(): Promise<void> {
if (this.running) {
new Notice("Stardust Importer: a pull is already running.");
return;
}
this.running = true;
try {
let created = 0;
if (this.settings.enabled.trakt && this.trakt.isAuthed()) created += await this.importMovies(false);
if (this.settings.enabled.lastfm && this.lastfm.isConfigured()) created += await this.importAlbums();
if (created > 0) new Notice(`Stardust Importer: added ${created} note(s).`);
} finally {
this.running = false;
}
}
async backfillTrakt(): Promise<void> {
if (!this.settings.enabled.trakt) {
new Notice("Stardust Importer: Trakt is disabled in settings.");
return;
}
if (!this.trakt.isAuthed()) {
new Notice("Stardust Importer: connect Trakt first.");
return;
}
new Notice("Stardust Importer: backfilling Trakt movies…");
const n = await this.importMovies(true);
this.settings.backfillDoneTrakt = true;
await this.saveSettings();
new Notice(`Stardust Importer: backfill added ${n} movie note(s).`);
}
private async importMovies(backfill: boolean): Promise<number> {
const startAt = backfill ? undefined : this.settings.trakt.lastScanned || undefined;
let movies;
try {
movies = await this.trakt.fetchWatchedMovies(startAt);
} catch (e) {
console.error("Stardust Importer: Trakt fetch failed", e);
new Notice("Stardust Importer: Trakt fetch failed (see console).");
return 0;
}
let count = 0;
for (const m of movies) {
if (!m.tmdbId) continue;
const key = String(m.tmdbId);
if (this.settings.seenMovies[key]) {
// rewatch — update the existing note rather than duplicate
const file = await this.notes.findByFrontmatter("tmdbId", m.tmdbId);
if (file) await this.notes.updateFrontmatter(file, { last: m.watchedAt, rewatched: true });
this.settings.seenMovies[key] = m.watchedAt;
continue;
}
const base = sanitizeTitle(m.title) || m.title;
const file = await this.notes.createFromTemplate(this.settings.movieTemplatePath, base, {
tmdbId: m.tmdbId,
title: m.title,
watchedAt: m.watchedAt
});
if (file) {
count++;
this.settings.seenMovies[key] = m.watchedAt;
}
}
this.settings.trakt.lastScanned = new Date().toISOString();
await this.saveSettings();
return count;
}
private async importAlbums(): Promise<number> {
const from = this.settings.lastfm.lastScanned;
// First ever run: start the watermark now; do NOT sweep history.
if (from === 0) {
this.settings.lastfm.lastScanned = Math.floor(Date.now() / 1000);
await this.saveSettings();
return 0;
}
let scrobbles;
try {
scrobbles = await this.lastfm.fetchRecentTracks(from);
} catch (e) {
console.error("Stardust Importer: Last.fm fetch failed", e);
new Notice("Stardust Importer: Last.fm fetch failed (see console).");
return 0;
}
if (!scrobbles.length) {
this.settings.lastfm.lastScanned = Math.floor(Date.now() / 1000);
await this.saveSettings();
return 0;
}
const listens = await detectAlbumListens(scrobbles, this.settings.session, (a, al) =>
this.lastfm.albumTrackCount(a, al)
);
let count = 0;
for (const l of listens) {
const key = `${l.artist.toLowerCase()}::${l.album.toLowerCase()}`;
if (this.settings.seenAlbums[key]) {
const path = this.settings.seenAlbums[key].path;
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) await this.notes.updateFrontmatter(file, { relistened_14d: true });
this.settings.seenAlbums[key] = { date: l.listenedAt, path };
continue;
}
const base = sanitizeTitle(l.album) || l.album;
const file = await this.notes.createFromTemplate(this.settings.albumTemplatePath, base, {
artist: l.artist,
album: l.album,
year: "",
trackCount: l.trackCount,
listenedAt: l.listenedAt
});
if (file) {
count++;
this.settings.seenAlbums[key] = { date: l.listenedAt, path: file.path };
}
}
const newest = scrobbles.reduce((mx, s) => Math.max(mx, s.uts), from);
this.settings.lastfm.lastScanned = newest + 1;
await this.saveSettings();
return count;
}
// ---- Trakt device auth ------------------------------------------------
async connectTrakt(): Promise<void> {
const s = this.settings.trakt;
if (!s.clientId || !s.clientSecret) {
new Notice("Stardust Importer: set the Trakt client ID & secret first.");
return;
}
let dc: DeviceCode;
try {
dc = await this.trakt.requestDeviceCode();
} catch (e) {
console.error(e);
new Notice("Stardust Importer: Trakt device-code request failed.");
return;
}
const modal = new TraktAuthModal(this.app, dc);
modal.open();
window.open(dc.verification_url, "_blank");
const ok = await this.trakt.pollForToken(dc.device_code, dc.interval, dc.expires_in);
modal.close();
new Notice(ok ? "Stardust Importer: Trakt connected." : "Stardust Importer: Trakt connection failed or timed out.");
}
}
class TraktAuthModal extends Modal {
constructor(app: App, private dc: DeviceCode) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
this.setTitle("Connect Trakt");
contentEl.createEl("p", { text: "A browser tab is opening. Enter this code there to authorize:" });
contentEl.createEl("div", { text: this.dc.user_code, cls: "stardust-trakt-code" });
contentEl.createEl("p", { text: this.dc.verification_url, cls: "mod-muted" });
contentEl.createEl("p", { text: "Waiting for authorization… you can close this once it's done." });
}
onClose(): void {
this.contentEl.empty();
}
}