feat: initial commit — Stardust Importer

Obsidian plugin that pulls watched movies (Trakt) and full-album
listens (Last.fm) into notes via your own Templater templates.
Renamed and generalized from the personal harker-importer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sonophage 2026-06-15 13:25:52 -07:00
commit 8302e0497b
16 changed files with 1636 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
main.js
data.json
*.log

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sonophage
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

33
README.md Normal file
View file

@ -0,0 +1,33 @@
# Stardust Importer
An Obsidian plugin that pulls **watched movies** (Trakt) and **full-album listens** (Last.fm) into notes in your vault, rendered through your own Movie/Album Templater templates. Named for Ziggy Stardust — the guise of *Media*, the New God of the screen.
## What it does
- **Movies** — on launch (after a delay), fetches new entries from Trakt's `/users/me/history/movies` (movies only — TV never leaks in) and creates `Movies - <title>` notes. Re-watches update `last:` + `rewatched: true` instead of duplicating.
- **Albums** — reconstructs *full-album listens* from Last.fm scrobbles using a **session detector**: a sitting counts as an album listen when one album dominates a gap-bounded session and ≥ a coverage threshold of its tracklist was played. Creates `Albums - <title>` notes. Re-listens set `relistened_14d: true`.
## How notes are created
The plugin doesn't reproduce the note schema — it hands data to the **existing templates** via a filename-keyed handshake. Each template's top `<%* %>` block reads:
```js
const __P = app.plugins.plugins["stardust-importer"]?.pending?.[tp.file.title];
```
If a payload is present it skips the interactive prompts and fills from it; otherwise the template behaves exactly as before for manual note creation.
- **Movie template** — payload `{ tmdbId, title, watchedAt }`; reuses all TMDB enrichment, just skips the search/suggester/date prompts.
- **Album template** — payload `{ artist, album, year, trackCount, listenedAt }`; auto-selects the best MusicBrainz release-group (artist + title + closest year). On a low-confidence match it still attaches the best guess, sets `needs_review: true`, and adds a warning callout (option *b*).
## Setup
1. `npm install && npm run build`
2. Symlink into the vault: `.obsidian/plugins/stardust-importer` → this repo, enable the plugin.
3. In settings: add Trakt client ID/secret, **Connect Trakt** (device auth), add Last.fm API key + username.
4. **Backfill now** (Trakt only) for movie history. Last.fm starts watching from first run forward — no history sweep.
## Notes
- Uses Templater's internal `write_template_to_file` — fine for a pinned personal install; a future Templater rewrite could rename it.
- State (tokens, `lastScanned` watermarks, seen movies/albums) lives in this plugin's `data.json`.

27
esbuild.config.mjs Normal file
View file

@ -0,0 +1,27 @@
import esbuild from "esbuild";
import process from "process";
import { builtinModules } from "node:module";
const production = process.argv[2] === "production";
const builtins = builtinModules.flatMap((m) => [m, `node:${m}`]);
const context = await esbuild.context({
banner: { js: "/* Harker Importer — Obsidian plugin by Sonophage. */" },
entryPoints: ["main.ts"],
bundle: true,
external: ["obsidian", "electron", ...builtins],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: production ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: production
});
if (production) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

247
main.ts Normal file
View file

@ -0,0 +1,247 @@
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 } 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, any> = {};
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);
document.body.style.removeProperty("--tmdb-api-key");
document.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 {
document.body.style.setProperty("--tmdb-api-key", this.settings.mediaKeys.tmdb);
document.body.style.setProperty("--steamgriddb-api-key", this.settings.mediaKeys.steamgriddb);
}
async loadSettings(): Promise<void> {
const data = (await this.loadData()) ?? {};
// 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.trakt.isAuthed()) created += await this.importMovies(false);
if (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.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;
contentEl.createEl("h3", { text: "Connect Trakt" });
contentEl.createEl("p", { text: "A browser tab is opening. Enter this code there to authorize:" });
const code = contentEl.createEl("div", { text: this.dc.user_code });
code.style.fontSize = "2em";
code.style.fontWeight = "bold";
code.style.letterSpacing = "0.15em";
code.style.textAlign = "center";
code.style.margin = "0.5em 0";
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();
}
}

9
manifest.json Normal file
View file

@ -0,0 +1,9 @@
{
"id": "stardust-importer",
"name": "Stardust Importer",
"version": "0.1.0",
"minAppVersion": "1.5.0",
"description": "Pulls watched movies (Trakt) and full-album listens (Last.fm) into reference notes via Templater.",
"author": "Sonophage",
"isDesktopOnly": false
}

589
package-lock.json generated Normal file
View file

@ -0,0 +1,589 @@
{
"name": "stardust-importer",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stardust-importer",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.11.30",
"esbuild": "^0.21.5",
"obsidian": "^1.5.12",
"typescript": "^5.4.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
"integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.38.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/codemirror": {
"version": "5.60.8",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz",
"integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/tern": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
"integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/tern": {
"version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
"integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/moment": {
"version": "2.29.4",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/obsidian": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.13.0.tgz",
"integrity": "sha512-PHw5+SAPlJ0S3leFvJ0wgFg63Z3DavxL6+d1ll+8toXR2ZlYKc1rMWqdUv9LgUbTwPQUyY6yfhOMMivampRRiQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/codemirror": "5.60.8",
"moment": "2.29.4"
},
"peerDependencies": {
"@codemirror/state": "6.5.0",
"@codemirror/view": "6.38.6"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"dev": true,
"license": "MIT",
"peer": true
}
}
}

19
package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "stardust-importer",
"version": "0.1.0",
"description": "Pulls watched movies (Trakt) and full-album listens (Last.fm) into reference notes via Templater.",
"main": "main.js",
"scripts": {
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"dev": "node esbuild.config.mjs"
},
"keywords": ["obsidian", "plugin", "trakt", "lastfm", "templater"],
"author": "Sonophage",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.11.30",
"esbuild": "^0.21.5",
"obsidian": "^1.5.12",
"typescript": "^5.4.5"
}
}

89
src/lastfm.ts Normal file
View file

@ -0,0 +1,89 @@
import { requestUrl } from "obsidian";
import type StardustImporter from "../main";
const LFM = "https://ws.audioscrobbler.com/2.0/";
export interface Scrobble {
artist: string;
album: string;
track: string;
uts: number; // unix seconds
}
export class LastfmClient {
private trackCountCache: Record<string, number> = {};
constructor(private plugin: StardustImporter) {}
private get s() {
return this.plugin.settings.lastfm;
}
isConfigured(): boolean {
return !!this.s.apiKey && !!this.s.username;
}
// Recent scrobbles since `from` (unix seconds). from <= 0 => no lower bound.
async fetchRecentTracks(from: number): Promise<Scrobble[]> {
const out: Scrobble[] = [];
let page = 1;
let totalPages = 1;
do {
const params = new URLSearchParams({
method: "user.getrecenttracks",
user: this.s.username,
api_key: this.s.apiKey,
format: "json",
limit: "200",
page: String(page)
});
if (from > 0) params.set("from", String(from));
const res = await requestUrl({ url: `${LFM}?${params.toString()}`, method: "GET", throw: false });
if (res.status !== 200) break;
const rt = (res.json as any)?.recenttracks;
if (!rt) break;
const attr = rt["@attr"] || {};
totalPages = parseInt(attr.totalPages || "1") || 1;
const tracks = Array.isArray(rt.track) ? rt.track : rt.track ? [rt.track] : [];
for (const t of tracks) {
if (t["@attr"] && t["@attr"].nowplaying === "true") continue; // no timestamp
const uts = parseInt(t.date?.uts || "0");
if (!uts) continue;
out.push({
artist: String(t.artist?.["#text"] || t.artist?.name || "").trim(),
album: String(t.album?.["#text"] || "").trim(),
track: String(t.name || "").trim(),
uts
});
}
page++;
} while (page <= totalPages);
return out;
}
// Total tracks on an album (cached). 0 if unknown.
async albumTrackCount(artist: string, album: string): Promise<number> {
const key = `${artist.toLowerCase()}::${album.toLowerCase()}`;
if (this.trackCountCache[key] != null) return this.trackCountCache[key];
const params = new URLSearchParams({
method: "album.getinfo",
artist,
album,
api_key: this.s.apiKey,
autocorrect: "1",
format: "json"
});
let count = 0;
try {
const res = await requestUrl({ url: `${LFM}?${params.toString()}`, method: "GET", throw: false });
if (res.status === 200) {
const tr = (res.json as any)?.album?.tracks?.track;
count = Array.isArray(tr) ? tr.length : tr ? 1 : 0;
}
} catch (e) {
/* leave count 0 */
}
this.trackCountCache[key] = count;
return count;
}
}

74
src/notes.ts Normal file
View file

@ -0,0 +1,74 @@
import { Notice, TFile, normalizePath } from "obsidian";
import type StardustImporter from "../main";
// Mirror the sanitization the Movie/Album templates apply when renaming.
export function sanitizeTitle(title: string): string {
const colonFixed = title.replace(/:/g, " - ");
return colonFixed.replace(/[\/#%&{}<>*?$!'"@+`|=]/g, "").trim();
}
export class NoteWriter {
constructor(private plugin: StardustImporter) {}
private get app() {
return this.plugin.app;
}
private get folder(): string {
return this.plugin.settings.mediaFolder;
}
private templater(): any {
const t = (this.app as any).plugins.plugins["templater-obsidian"];
return t?.templater ?? null;
}
// Create a note from a Templater template, handing it the payload (keyed by
// basename == tp.file.title). Returns the file, or null on failure.
async createFromTemplate(templatePath: string, basename: string, payload: any): Promise<TFile | null> {
const tmpl = this.app.vault.getAbstractFileByPath(normalizePath(templatePath));
if (!(tmpl instanceof TFile)) {
new Notice(`Stardust Importer: template not found — ${templatePath}`);
return null;
}
const templater = this.templater();
if (!templater) {
new Notice("Stardust Importer: Templater is not available.");
return null;
}
const targetPath = normalizePath(`${this.folder}/${basename}.md`);
if (await this.app.vault.adapter.exists(targetPath)) {
const existing = this.app.vault.getAbstractFileByPath(targetPath);
return existing instanceof TFile ? existing : null;
}
this.plugin.pending[basename] = payload;
try {
const file = await this.app.vault.create(targetPath, "");
await templater.write_template_to_file(tmpl, file);
return file;
} catch (e) {
console.error("Stardust Importer: note creation failed", e);
new Notice(`Stardust Importer: failed to create "${basename}".`);
return null;
} finally {
delete this.plugin.pending[basename];
}
}
async findByFrontmatter(field: string, value: string | number): Promise<TFile | null> {
const prefix = this.folder + "/";
const files = this.app.vault.getMarkdownFiles().filter((f) => f.path.startsWith(prefix));
for (const f of files) {
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter;
if (fm && String(fm[field]) === String(value)) return f;
}
return null;
}
async updateFrontmatter(file: TFile, patch: Record<string, unknown>): Promise<void> {
await this.app.fileManager.processFrontMatter(file, (fm: Record<string, unknown>) => {
for (const [k, v] of Object.entries(patch)) fm[k] = v;
});
}
}

96
src/sessions.ts Normal file
View file

@ -0,0 +1,96 @@
import type { Scrobble } from "./lastfm";
export interface AlbumListen {
artist: string;
album: string;
trackCount: number; // album total (from Last.fm)
distinct: number; // distinct tracks played in the session
listenedAt: string; // YYYY-MM-DD
}
export interface SessionConfig {
gapMinutes: number;
coveragePct: number;
minTracks: number;
}
interface AlbumRun {
artist: string;
album: string;
tracks: Set<string>;
lastUts: number;
}
// Detect full-album listens from a flat scrobble list.
// `trackCounter` resolves an album's total track count (cached upstream).
export async function detectAlbumListens(
scrobbles: Scrobble[],
cfg: SessionConfig,
trackCounter: (artist: string, album: string) => Promise<number>
): Promise<AlbumListen[]> {
const sorted = scrobbles.filter((s) => s.album).sort((a, b) => a.uts - b.uts);
const gap = cfg.gapMinutes * 60;
// 1) split into sessions on a listening gap
const sessions: Scrobble[][] = [];
let cur: Scrobble[] = [];
for (const s of sorted) {
if (cur.length && s.uts - cur[cur.length - 1].uts > gap) {
sessions.push(cur);
cur = [];
}
cur.push(s);
}
if (cur.length) sessions.push(cur);
// 2) within each session, find the dominant album and test coverage
const found: AlbumListen[] = [];
for (const session of sessions) {
const byAlbum = new Map<string, AlbumRun>();
for (const s of session) {
const key = `${s.artist.toLowerCase()}::${s.album.toLowerCase()}`;
let g = byAlbum.get(key);
if (!g) {
g = { artist: s.artist, album: s.album, tracks: new Set(), lastUts: 0 };
byAlbum.set(key, g);
}
g.tracks.add(s.track.toLowerCase());
if (s.uts > g.lastUts) g.lastUts = s.uts;
}
let best: AlbumRun | null = null;
for (const g of byAlbum.values()) {
if (!best || g.tracks.size > best.tracks.size) best = g;
}
if (!best || best.tracks.size < cfg.minTracks) continue;
const total = await trackCounter(best.artist, best.album);
if (total < cfg.minTracks) continue;
const coverage = (best.tracks.size / total) * 100;
if (coverage < cfg.coveragePct) continue;
found.push({
artist: best.artist,
album: best.album,
trackCount: total,
distinct: best.tracks.size,
listenedAt: isoDate(best.lastUts)
});
}
// 3) collapse same album across sessions to its latest listen
const map = new Map<string, AlbumListen>();
for (const l of found) {
const key = `${l.artist.toLowerCase()}::${l.album.toLowerCase()}`;
const prev = map.get(key);
if (!prev || l.listenedAt > prev.listenedAt) map.set(key, l);
}
return [...map.values()];
}
function isoDate(uts: number): string {
const d = new Date(uts * 1000);
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}

184
src/settings-tab.ts Normal file
View file

@ -0,0 +1,184 @@
import { App, PluginSettingTab, Setting } from "obsidian";
import type StardustImporter from "../main";
export class StardustImporterSettingTab extends PluginSettingTab {
constructor(app: App, private plugin: StardustImporter) {
super(app, plugin);
}
display(): void {
const { containerEl } = this;
containerEl.empty();
const s = this.plugin.settings;
// ---- Trakt ----------------------------------------------------------
containerEl.createEl("h3", { text: "Trakt (movies)" });
new Setting(containerEl)
.setName("Client ID")
.setDesc("From your Trakt API app (trakt.tv/oauth/applications).")
.addText((t) =>
t.setValue(s.trakt.clientId).onChange(async (v) => {
s.trakt.clientId = v.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl).setName("Client Secret").addText((t) => {
t.inputEl.type = "password";
t.setValue(s.trakt.clientSecret).onChange(async (v) => {
s.trakt.clientSecret = v.trim();
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName("Connection")
.setDesc(this.plugin.trakt.isAuthed() ? "Connected." : "Not connected.")
.addButton((b) =>
b.setButtonText(this.plugin.trakt.isAuthed() ? "Reconnect" : "Connect").onClick(() => this.plugin.connectTrakt())
);
new Setting(containerEl)
.setName("Backfill movies")
.setDesc("One-time: pull your full Trakt movie history.")
.addButton((b) => b.setButtonText("Backfill now").onClick(() => this.plugin.backfillTrakt()));
// ---- Last.fm --------------------------------------------------------
containerEl.createEl("h3", { text: "Last.fm (albums)" });
new Setting(containerEl)
.setName("API key")
.setDesc("From last.fm/api/account/create. Reads only — no OAuth needed.")
.addText((t) =>
t.setValue(s.lastfm.apiKey).onChange(async (v) => {
s.lastfm.apiKey = v.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl).setName("Username").addText((t) =>
t.setValue(s.lastfm.username).onChange(async (v) => {
s.lastfm.username = v.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Album session detection")
.setDesc("A sitting counts as an album listen when one album dominates the session.")
.setHeading();
new Setting(containerEl)
.setName("Session gap (minutes)")
.setDesc("A gap longer than this starts a new listening session.")
.addText((t) =>
t.setValue(String(s.session.gapMinutes)).onChange(async (v) => {
const n = parseInt(v);
if (!isNaN(n) && n > 0) {
s.session.gapMinutes = n;
await this.plugin.saveSettings();
}
})
);
new Setting(containerEl)
.setName("Coverage threshold (%)")
.setDesc("Minimum share of the album's tracklist played in one session.")
.addText((t) =>
t.setValue(String(s.session.coveragePct)).onChange(async (v) => {
const n = parseInt(v);
if (!isNaN(n) && n > 0 && n <= 100) {
s.session.coveragePct = n;
await this.plugin.saveSettings();
}
})
);
new Setting(containerEl)
.setName("Minimum album length (tracks)")
.setDesc("Albums shorter than this are ignored (excludes EPs/singles).")
.addText((t) =>
t.setValue(String(s.session.minTracks)).onChange(async (v) => {
const n = parseInt(v);
if (!isNaN(n) && n > 0) {
s.session.minTracks = n;
await this.plugin.saveSettings();
}
})
);
// ---- Media card API keys -------------------------------------------
containerEl.createEl("h3", { text: "Media card API keys" });
new Setting(containerEl)
.setName("TMDB API key")
.setDesc(
"themoviedb.org v3 key — powers the Movies & Shows cards (live cast/trailers) and the Movie/Show templates. Published to <body> as --tmdb-api-key."
)
.addText((t) =>
t.setValue(s.mediaKeys.tmdb).onChange(async (v) => {
s.mediaKeys.tmdb = v.trim();
await this.plugin.saveSettings();
this.plugin.applyMediaKeys();
})
);
new Setting(containerEl)
.setName("SteamGridDB API key")
.setDesc(
"steamgriddb.com read-only key — the Video Game template uses it for hero art + logo. Published to <body> as --steamgriddb-api-key."
)
.addText((t) =>
t.setValue(s.mediaKeys.steamgriddb).onChange(async (v) => {
s.mediaKeys.steamgriddb = v.trim();
await this.plugin.saveSettings();
this.plugin.applyMediaKeys();
})
);
// ---- General --------------------------------------------------------
containerEl.createEl("h3", { text: "General" });
new Setting(containerEl)
.setName("Media folder")
.addText((t) =>
t.setValue(s.mediaFolder).onChange(async (v) => {
s.mediaFolder = v.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl).setName("Movie template path").addText((t) =>
t.setValue(s.movieTemplatePath).onChange(async (v) => {
s.movieTemplatePath = v.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl).setName("Album template path").addText((t) =>
t.setValue(s.albumTemplatePath).onChange(async (v) => {
s.albumTemplatePath = v.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Startup delay (seconds)")
.setDesc("How long after Obsidian opens before the incremental pull runs.")
.addText((t) =>
t.setValue(String(s.startupDelaySec)).onChange(async (v) => {
const n = parseInt(v);
if (!isNaN(n) && n >= 0) {
s.startupDelaySec = n;
await this.plugin.saveSettings();
}
})
);
new Setting(containerEl)
.setName("Pull now")
.setDesc("Run an incremental pull immediately.")
.addButton((b) => b.setButtonText("Pull").onClick(() => this.plugin.runPull()));
}
}

72
src/settings.ts Normal file
View file

@ -0,0 +1,72 @@
export interface SeenAlbum {
date: string;
path: string;
}
export interface StardustImporterSettings {
trakt: {
clientId: string;
clientSecret: string;
accessToken: string;
refreshToken: string;
tokenExpiresAt: number; // epoch ms
lastScanned: string; // ISO timestamp; "" => never pulled
};
lastfm: {
apiKey: string;
username: string;
lastScanned: number; // unix seconds; 0 => never pulled (start watching from now)
};
// Keys consumed by the media cards (custom-views) and the Movie/Show/Game
// Templater templates. The plugin publishes them as CSS custom properties on
// <body> (--tmdb-api-key / --steamgriddb-api-key); those consumers read them
// via getComputedStyle. Lives here instead of a Style Settings snippet.
mediaKeys: {
tmdb: string;
steamgriddb: string;
};
mediaFolder: string;
movieTemplatePath: string;
albumTemplatePath: string;
startupDelaySec: number;
session: {
gapMinutes: number;
coveragePct: number;
minTracks: number;
};
seenMovies: Record<string, string>; // tmdbId -> last watched date
seenAlbums: Record<string, SeenAlbum>; // "artist::album" -> {date, path}
backfillDoneTrakt: boolean;
}
export const DEFAULT_SETTINGS: StardustImporterSettings = {
trakt: {
clientId: "",
clientSecret: "",
accessToken: "",
refreshToken: "",
tokenExpiresAt: 0,
lastScanned: ""
},
lastfm: {
apiKey: "",
username: "",
lastScanned: 0
},
mediaKeys: {
tmdb: "",
steamgriddb: ""
},
mediaFolder: "Media",
movieTemplatePath: "The Noetic/Templates/Movie Template.md",
albumTemplatePath: "The Noetic/Templates/Album Template.md",
startupDelaySec: 90,
session: {
gapMinutes: 30,
coveragePct: 70,
minTracks: 5
},
seenMovies: {},
seenAlbums: {},
backfillDoneTrakt: false
};

151
src/trakt.ts Normal file
View file

@ -0,0 +1,151 @@
import { requestUrl } from "obsidian";
import type StardustImporter from "../main";
const TRAKT_API = "https://api.trakt.tv";
export interface TraktMovie {
historyId: number;
watchedAt: string; // YYYY-MM-DD
title: string;
year: number | null;
tmdbId: number | null;
imdbId: string | null;
}
export interface DeviceCode {
device_code: string;
user_code: string;
verification_url: string;
expires_in: number;
interval: number;
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
export class TraktClient {
constructor(private plugin: StardustImporter) {}
private get s() {
return this.plugin.settings.trakt;
}
private headers(): Record<string, string> {
const h: Record<string, string> = {
"Content-Type": "application/json",
"trakt-api-version": "2",
"trakt-api-key": this.s.clientId
};
if (this.s.accessToken) h["Authorization"] = `Bearer ${this.s.accessToken}`;
return h;
}
isAuthed(): boolean {
return !!this.s.accessToken;
}
async requestDeviceCode(): Promise<DeviceCode> {
const res = await requestUrl({
url: `${TRAKT_API}/oauth/device/code`,
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: this.s.clientId })
});
return res.json as DeviceCode;
}
// Poll the token endpoint until the user authorizes, or we time out.
async pollForToken(deviceCode: string, intervalSec: number, expiresInSec: number): Promise<boolean> {
const deadline = Date.now() + expiresInSec * 1000;
let interval = Math.max(intervalSec, 1) * 1000;
while (Date.now() < deadline) {
await sleep(interval);
const res = await requestUrl({
url: `${TRAKT_API}/oauth/device/token`,
method: "POST",
headers: { "Content-Type": "application/json" },
throw: false,
body: JSON.stringify({
code: deviceCode,
client_id: this.s.clientId,
client_secret: this.s.clientSecret
})
});
if (res.status === 200) {
this.saveTokens(res.json);
return true;
}
if (res.status === 400) continue; // authorization pending
if (res.status === 429) {
interval += 1000; // slow down
continue;
}
return false; // 404 invalid / 409 used / 410 expired / 418 denied
}
return false;
}
private saveTokens(t: any): void {
this.s.accessToken = t.access_token;
this.s.refreshToken = t.refresh_token;
this.s.tokenExpiresAt = (t.created_at + t.expires_in) * 1000;
void this.plugin.saveSettings();
}
async ensureFreshToken(): Promise<void> {
if (!this.s.refreshToken) return;
if (Date.now() < this.s.tokenExpiresAt - 60_000) return;
const res = await requestUrl({
url: `${TRAKT_API}/oauth/token`,
method: "POST",
headers: { "Content-Type": "application/json" },
throw: false,
body: JSON.stringify({
refresh_token: this.s.refreshToken,
client_id: this.s.clientId,
client_secret: this.s.clientSecret,
redirect_uri: "urn:ietf:wg:oauth:2.0:oob",
grant_type: "refresh_token"
})
});
if (res.status === 200) this.saveTokens(res.json);
}
// Watched movies, newest first. With startAt (ISO), only newer history.
async fetchWatchedMovies(startAt?: string): Promise<TraktMovie[]> {
await this.ensureFreshToken();
const out: TraktMovie[] = [];
let page = 1;
let pageCount = 1;
const limit = 100;
do {
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (startAt) params.set("start_at", startAt);
const res = await requestUrl({
url: `${TRAKT_API}/users/me/history/movies?${params.toString()}`,
method: "GET",
headers: this.headers(),
throw: false
});
if (res.status !== 200) break;
const items = (res.json as any[]) || [];
for (const it of items) {
const m = it.movie || {};
const ids = m.ids || {};
out.push({
historyId: it.id,
watchedAt: String(it.watched_at || "").slice(0, 10),
title: m.title || "",
year: m.year ?? null,
tmdbId: ids.tmdb ?? null,
imdbId: ids.imdb ?? null
});
}
const pc = parseInt(res.headers["x-pagination-page-count"] || "1");
pageCount = isNaN(pc) ? 1 : pc;
page++;
} while (page <= pageCount);
return out;
}
}

18
tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2020",
"allowJs": false,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"skipLibCheck": true,
"lib": ["DOM", "ES2020"]
},
"include": ["src/**/*.ts", "main.ts"]
}

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"0.1.0": "1.5.0"
}