mirror of
https://github.com/sonophage/stardust-importer.git
synced 2026-07-22 07:48:44 +00:00
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:
commit
8302e0497b
16 changed files with 1636 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
main.js
|
||||
data.json
|
||||
*.log
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
33
README.md
Normal 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
27
esbuild.config.mjs
Normal 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
247
main.ts
Normal 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
9
manifest.json
Normal 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
589
package-lock.json
generated
Normal 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
19
package.json
Normal 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
89
src/lastfm.ts
Normal 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
74
src/notes.ts
Normal 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
96
src/sessions.ts
Normal 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
184
src/settings-tab.ts
Normal 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
72
src/settings.ts
Normal 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
151
src/trakt.ts
Normal 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
18
tsconfig.json
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.1.0": "1.5.0"
|
||||
}
|
||||
Loading…
Reference in a new issue