mirror of
https://github.com/sonophage/stardust-importer.git
synced 2026-07-22 07:48:44 +00:00
- 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>
247 lines
9 KiB
TypeScript
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();
|
|
}
|
|
}
|