@coffer-org/plugin-media 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ declare const _default: import("@coffer-org/sdk/plugin").PluginManifest;
2
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ import { defineVault } from '@coffer-org/sdk/vault';
2
+ import { definePlugin } from '@coffer-org/sdk/plugin';
3
+ import { field } from '@coffer-org/sdk/fields';
4
+ import { defineSettings } from '@coffer-org/sdk/settings';
5
+ import title from "./title/index.js";
6
+ export default definePlugin({
7
+ id: 'media',
8
+ version: '1.0.0',
9
+ dependsOn: [],
10
+ vaults: [
11
+ {
12
+ meta: defineVault({ id: 'media', label: 'media.vault.label', icon: 'lucide:clapperboard' }),
13
+ modules: [title],
14
+ },
15
+ ],
16
+ settings: defineSettings({
17
+ label: 'media.settings.label',
18
+ fields: [
19
+ field.password({ key: 'tmdb_key', label: 'media.settings.tmdb_key', required: true }),
20
+ field.password({ key: 'omdb_key', label: 'media.settings.omdb_key' }),
21
+ field.string({ key: 'jellyfin_url', label: 'media.settings.jellyfin_url' }),
22
+ field.password({ key: 'jellyfin_token', label: 'media.settings.jellyfin_token' }),
23
+ field.string({ key: 'jellyfin_user_id', label: 'media.settings.jellyfin_user_id' }),
24
+ field.button({ label: 'media.settings.test_button', value: 'media.testJellyfin', icon: 'lucide:wifi' }),
25
+ field.button({ label: 'media.settings.sync_button', value: 'media.syncJellyfin', icon: 'lucide:refresh-cw' }),
26
+ ],
27
+ }),
28
+ });
@@ -0,0 +1,5 @@
1
+ export declare function downloadImage(url: string, headers?: Record<string, string>, fetchFn?: typeof fetch): Promise<{
2
+ name: string;
3
+ mime?: string;
4
+ size?: number;
5
+ } | undefined>;
@@ -0,0 +1,22 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { extname, join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { uploadsDir } from '@coffer-org/server/uploads';
5
+ export async function downloadImage(url, headers, fetchFn = fetch) {
6
+ try {
7
+ const resp = await fetchFn(url, headers ? { headers } : undefined);
8
+ if (!resp.ok)
9
+ return undefined;
10
+ const buf = Buffer.from(await resp.arrayBuffer());
11
+ if (buf.length === 0)
12
+ return undefined;
13
+ const mime = resp.headers.get('content-type') ?? undefined;
14
+ const ext = extname(new URL(url).pathname) || (mime?.includes('png') ? '.png' : '.jpg');
15
+ const name = `${randomUUID()}${ext}`;
16
+ await writeFile(join(uploadsDir(), name), buf);
17
+ return { name, mime, size: buf.length };
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
@@ -0,0 +1,6 @@
1
+ import type { PluginHooks } from '@coffer-org/server/plugin-hooks';
2
+ export { runJellyfinSync } from './sync.ts';
3
+ export { testConnection } from './jellyfin.ts';
4
+ export declare function addTitle(query?: string, tmdbId?: string, kind?: 'movie' | 'series', source?: string): Promise<Record<string, unknown>>;
5
+ export declare function startSync(): Promise<void>;
6
+ export declare const serverHooks: PluginHooks;
@@ -0,0 +1,89 @@
1
+ import { z } from 'zod';
2
+ import { getPluginSettings } from '@coffer-org/server/plugin-runtime';
3
+ import { localPost } from '@coffer-org/server/local-api';
4
+ import { runJellyfinSync } from "./sync.js";
5
+ import { searchTitle, getDetails } from "./tmdb.js";
6
+ import { getRating } from "./omdb.js";
7
+ import { downloadImage } from "./files.js";
8
+ export { runJellyfinSync } from "./sync.js";
9
+ export { testConnection } from "./jellyfin.js";
10
+ export async function addTitle(query, tmdbId, kind, source = 'agent') {
11
+ const s = await getPluginSettings('media');
12
+ const tmdbKey = s['tmdb_key'];
13
+ const omdbKey = s['omdb_key'];
14
+ if (!tmdbKey)
15
+ throw new Error('TMDB API key not configured. Save it in media settings first.');
16
+ let id = tmdbId;
17
+ let resolvedKind = kind;
18
+ if (!id) {
19
+ if (!query)
20
+ throw new Error('Provide a query or a tmdb_id.');
21
+ const hits = await searchTitle(query, tmdbKey, kind);
22
+ const first = hits[0];
23
+ if (!first)
24
+ throw new Error(`No TMDB result for "${query}".`);
25
+ id = first.tmdb_id;
26
+ resolvedKind = first.kind;
27
+ }
28
+ const d = await getDetails(id, resolvedKind ?? 'movie', tmdbKey);
29
+ const imdb_rating = d.imdb_id ? await getRating(d.imdb_id, omdbKey) : null;
30
+ const poster = d.poster ? await downloadImage(d.poster) : undefined;
31
+ return localPost('media', 'title', {
32
+ name: d.name,
33
+ kind: d.kind,
34
+ poster,
35
+ year: d.year ?? undefined,
36
+ overview: d.overview || undefined,
37
+ genres: d.genres,
38
+ runtime: d.runtime ?? undefined,
39
+ tmdb_id: d.tmdb_id,
40
+ imdb_id: d.imdb_id ?? undefined,
41
+ tmdb_rating: d.tmdb_rating ?? undefined,
42
+ imdb_rating: imdb_rating ?? undefined,
43
+ seasons: d.seasons ?? undefined,
44
+ episodes: d.episodes ?? undefined,
45
+ link: d.link ?? undefined,
46
+ status: 'want',
47
+ source,
48
+ in_library: false,
49
+ });
50
+ }
51
+ export async function startSync() {
52
+ const r = await runJellyfinSync();
53
+ console.log(`[media] jellyfin sync: ${r.created} created, ${r.updated} updated`);
54
+ }
55
+ export const serverHooks = {
56
+ init: () => {
57
+ void startSync().catch((e) => console.warn(`[media] sync failed to start: ${e.message}`));
58
+ },
59
+ agent: {
60
+ instructions: 'Vault media — movies/series watchlist (title module). kind=movie/series. ' +
61
+ 'status: want=added to the list, not in Jellyfin yet; unwatched=in Jellyfin, not watched; watching; watched; dropped. ' +
62
+ 'in_library, jellyfin_id and source are mirrored from Jellyfin — do not edit manually when source=jellyfin. ' +
63
+ 'To add a movie by name — search_titles (review candidates) then add_title.',
64
+ tools: [
65
+ {
66
+ name: 'search_titles',
67
+ description: 'Search TMDB for movies/series by name. Returns candidates (does not create a record).',
68
+ inputSchema: { query: z.string(), kind: z.enum(['movie', 'series']).optional() },
69
+ handler: async (args) => {
70
+ const s = await getPluginSettings('media');
71
+ const key = s['tmdb_key'];
72
+ if (!key)
73
+ throw new Error('TMDB API key not configured.');
74
+ return searchTitle(args['query'], key, args['kind']);
75
+ },
76
+ },
77
+ {
78
+ name: 'add_title',
79
+ description: 'Creates a media__title record from TMDB by tmdb_id or by name (takes the first result).',
80
+ inputSchema: {
81
+ query: z.string().optional(),
82
+ tmdb_id: z.string().optional(),
83
+ kind: z.enum(['movie', 'series']).optional(),
84
+ },
85
+ handler: (args) => addTitle(args['query'], args['tmdb_id'], args['kind']),
86
+ },
87
+ ],
88
+ },
89
+ };
@@ -0,0 +1,29 @@
1
+ export interface JfItem {
2
+ jellyfin_id: string;
3
+ name: string;
4
+ kind: 'movie' | 'series';
5
+ tmdb_id: string | null;
6
+ imdb_id: string | null;
7
+ played: boolean;
8
+ position_ticks: number;
9
+ overview: string | null;
10
+ genres: string[];
11
+ year: number | null;
12
+ runtime: number | null;
13
+ rating: number | null;
14
+ seasons: number | null;
15
+ has_image: boolean;
16
+ }
17
+ export interface JfCreds {
18
+ url: string;
19
+ token: string;
20
+ userId: string;
21
+ }
22
+ export declare function normalizeItems(raw: unknown): JfItem[];
23
+ export declare function itemLink(creds: Pick<JfCreds, 'url'>, jellyfinId: string): string;
24
+ export declare function imageUrl(creds: Pick<JfCreds, 'url'>, jellyfinId: string): string;
25
+ export declare function fetchLibrary(creds: JfCreds, fetchFn?: typeof fetch): Promise<JfItem[]>;
26
+ export declare function testConnection(url: string, token: string, fetchFn?: typeof fetch): Promise<{
27
+ ok: boolean;
28
+ error?: string;
29
+ }>;
@@ -0,0 +1,62 @@
1
+ const str = (v) => (typeof v === 'string' && v ? v : null);
2
+ const num = (v) => (typeof v === 'number' ? v : null);
3
+ export function normalizeItems(raw) {
4
+ const items = raw?.['Items'] ?? [];
5
+ const out = [];
6
+ for (const it of items) {
7
+ const id = it['Id'];
8
+ const type = it['Type'];
9
+ if (typeof id !== 'string')
10
+ continue;
11
+ const kind = type === 'Movie' ? 'movie' : type === 'Series' ? 'series' : null;
12
+ if (!kind)
13
+ continue;
14
+ const pids = it['ProviderIds'] ?? {};
15
+ const ud = it['UserData'] ?? {};
16
+ const ticks = num(it['RunTimeTicks']);
17
+ const tags = it['ImageTags'] ?? {};
18
+ out.push({
19
+ jellyfin_id: id,
20
+ name: String(it['Name'] ?? ''),
21
+ kind,
22
+ tmdb_id: str(pids['Tmdb']),
23
+ imdb_id: str(pids['Imdb']),
24
+ played: ud['Played'] === true,
25
+ position_ticks: num(ud['PlaybackPositionTicks']) ?? 0,
26
+ overview: str(it['Overview']),
27
+ genres: Array.isArray(it['Genres']) ? it['Genres'].map(String) : [],
28
+ year: num(it['ProductionYear']),
29
+ runtime: ticks != null ? Math.round(ticks / 600_000_000) : null,
30
+ rating: num(it['CommunityRating']),
31
+ seasons: kind === 'series' ? num(it['ChildCount']) : null,
32
+ has_image: typeof tags['Primary'] === 'string',
33
+ });
34
+ }
35
+ return out;
36
+ }
37
+ export function itemLink(creds, jellyfinId) {
38
+ return `${creds.url.replace(/\/$/, '')}/web/#/details?id=${jellyfinId}`;
39
+ }
40
+ export function imageUrl(creds, jellyfinId) {
41
+ return `${creds.url.replace(/\/$/, '')}/Items/${jellyfinId}/Images/Primary?maxWidth=500`;
42
+ }
43
+ export async function fetchLibrary(creds, fetchFn = fetch) {
44
+ const base = creds.url.replace(/\/$/, '');
45
+ const fields = 'ProviderIds,UserData,Overview,Genres,ProductionYear,RunTimeTicks,CommunityRating,ChildCount';
46
+ const url = `${base}/Users/${creds.userId}/Items?Recursive=true&IncludeItemTypes=Movie,Series&Fields=${fields}`;
47
+ const resp = await fetchFn(url, { headers: { 'X-Emby-Token': creds.token, Accept: 'application/json' } });
48
+ if (!resp.ok)
49
+ throw new Error(`Jellyfin returned ${resp.status}`);
50
+ return normalizeItems(await resp.json());
51
+ }
52
+ export async function testConnection(url, token, fetchFn = fetch) {
53
+ try {
54
+ const resp = await fetchFn(`${url.replace(/\/$/, '')}/System/Info`, {
55
+ headers: { 'X-Emby-Token': token, Accept: 'application/json' },
56
+ });
57
+ return resp.ok ? { ok: true } : { ok: false, error: `Jellyfin returned ${resp.status}` };
58
+ }
59
+ catch (e) {
60
+ return { ok: false, error: e.message };
61
+ }
62
+ }
@@ -0,0 +1 @@
1
+ export declare function getRating(imdbId: string, key: string | undefined, fetchFn?: typeof fetch): Promise<number | null>;
@@ -0,0 +1,15 @@
1
+ export async function getRating(imdbId, key, fetchFn = fetch) {
2
+ if (!key || !imdbId)
3
+ return null;
4
+ try {
5
+ const resp = await fetchFn(`https://www.omdbapi.com/?apikey=${key}&i=${encodeURIComponent(imdbId)}`);
6
+ if (!resp.ok)
7
+ return null;
8
+ const json = (await resp.json());
9
+ const n = Number.parseFloat(json.imdbRating ?? '');
10
+ return Number.isFinite(n) ? n : null;
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
@@ -0,0 +1,25 @@
1
+ import { type JfItem } from './jellyfin.ts';
2
+ export interface ExistingTitle {
3
+ id: number;
4
+ tmdb_id?: string | null;
5
+ imdb_id?: string | null;
6
+ jellyfin_id?: string | null;
7
+ status?: string | null;
8
+ poster?: unknown;
9
+ }
10
+ export type SyncOp = {
11
+ kind: 'create';
12
+ id?: undefined;
13
+ jellyfin_id: string;
14
+ rec: Record<string, unknown>;
15
+ } | {
16
+ kind: 'update';
17
+ id: number;
18
+ jellyfin_id: string;
19
+ rec: Record<string, unknown>;
20
+ };
21
+ export declare function planSync(items: JfItem[], existing: ExistingTitle[], baseUrl: string): SyncOp[];
22
+ export declare function runJellyfinSync(fetchFn?: typeof fetch): Promise<{
23
+ created: number;
24
+ updated: number;
25
+ }>;
@@ -0,0 +1,86 @@
1
+ import { getPluginSettings } from '@coffer-org/server/plugin-runtime';
2
+ import { getModule } from '@coffer-org/server/registry-context';
3
+ import { moduleTableName } from '@coffer-org/server/entity-schema';
4
+ import { listRecords } from '@coffer-org/server/mutate';
5
+ import { localPost, localPatch } from '@coffer-org/server/local-api';
6
+ import { fetchLibrary, itemLink, imageUrl } from "./jellyfin.js";
7
+ import { downloadImage } from "./files.js";
8
+ const VAULT = 'media';
9
+ const MODULE = 'title';
10
+ function statusFor(it) {
11
+ if (it.played)
12
+ return 'watched';
13
+ if (it.position_ticks > 0)
14
+ return 'watching';
15
+ return 'unwatched';
16
+ }
17
+ function findMatch(it, link, existing) {
18
+ return (existing.find((e) => e.jellyfin_id === link) ||
19
+ (it.tmdb_id ? existing.find((e) => e.tmdb_id === it.tmdb_id) : undefined) ||
20
+ (it.imdb_id ? existing.find((e) => e.imdb_id === it.imdb_id) : undefined) ||
21
+ undefined);
22
+ }
23
+ export function planSync(items, existing, baseUrl) {
24
+ const ops = [];
25
+ for (const it of items) {
26
+ const link = itemLink({ url: baseUrl }, it.jellyfin_id);
27
+ const match = findMatch(it, link, existing);
28
+ const rec = {
29
+ name: it.name,
30
+ kind: it.kind,
31
+ tmdb_id: it.tmdb_id ?? undefined,
32
+ imdb_id: it.imdb_id ?? undefined,
33
+ jellyfin_id: link,
34
+ in_library: true,
35
+ source: 'jellyfin',
36
+ status: statusFor(it),
37
+ overview: it.overview ?? undefined,
38
+ genres: it.genres,
39
+ year: it.year ?? undefined,
40
+ runtime: it.runtime ?? undefined,
41
+ imdb_rating: it.rating ?? undefined,
42
+ seasons: it.seasons ?? undefined,
43
+ };
44
+ if (match)
45
+ ops.push({ kind: 'update', id: match.id, jellyfin_id: it.jellyfin_id, rec });
46
+ else
47
+ ops.push({ kind: 'create', jellyfin_id: it.jellyfin_id, rec });
48
+ }
49
+ return ops;
50
+ }
51
+ export async function runJellyfinSync(fetchFn = fetch) {
52
+ const s = await getPluginSettings('media');
53
+ const url = s['jellyfin_url'];
54
+ const token = s['jellyfin_token'];
55
+ const userId = s['jellyfin_user_id'];
56
+ if (!url || !token || !userId) {
57
+ throw new Error('Jellyfin settings not configured. Save URL, token and user id first.');
58
+ }
59
+ if (!getModule(VAULT, MODULE))
60
+ throw new Error('media vault not registered');
61
+ const creds = { url, token, userId };
62
+ const items = await fetchLibrary(creds, fetchFn);
63
+ const byJf = new Map(items.map((i) => [i.jellyfin_id, i]));
64
+ const ename = moduleTableName(VAULT, MODULE);
65
+ const existing = (await listRecords(ename));
66
+ let created = 0;
67
+ let updated = 0;
68
+ for (const op of planSync(items, existing, url)) {
69
+ const it = byJf.get(op.jellyfin_id);
70
+ const had = op.kind === 'update' && existing.find((e) => e.id === op.id)?.poster;
71
+ if (it?.has_image && !had) {
72
+ const poster = await downloadImage(imageUrl(creds, it.jellyfin_id), { 'X-Emby-Token': token }, fetchFn);
73
+ if (poster)
74
+ op.rec['poster'] = poster;
75
+ }
76
+ if (op.kind === 'update') {
77
+ await localPatch(VAULT, MODULE, op.id, op.rec);
78
+ updated++;
79
+ }
80
+ else {
81
+ await localPost(VAULT, MODULE, op.rec);
82
+ created++;
83
+ }
84
+ }
85
+ return { created, updated };
86
+ }
@@ -0,0 +1,21 @@
1
+ export interface TmdbCandidate {
2
+ tmdb_id: string;
3
+ kind: 'movie' | 'series';
4
+ name: string;
5
+ year: number | null;
6
+ poster: string | null;
7
+ overview: string;
8
+ }
9
+ export interface TitleDetails extends TmdbCandidate {
10
+ genres: string[];
11
+ runtime: number | null;
12
+ tmdb_rating: number | null;
13
+ imdb_id: string | null;
14
+ seasons: number | null;
15
+ episodes: number | null;
16
+ link: string | null;
17
+ }
18
+ export declare function normalizeSearch(raw: unknown): TmdbCandidate[];
19
+ export declare function normalizeDetails(raw: unknown, kind: 'movie' | 'series'): TitleDetails;
20
+ export declare function searchTitle(query: string, key: string, kind?: 'movie' | 'series', fetchFn?: typeof fetch): Promise<TmdbCandidate[]>;
21
+ export declare function getDetails(tmdbId: string, kind: 'movie' | 'series', key: string, fetchFn?: typeof fetch): Promise<TitleDetails>;
@@ -0,0 +1,65 @@
1
+ const IMG = 'https://image.tmdb.org/t/p/w500';
2
+ const API = 'https://api.themoviedb.org/3';
3
+ const yearOf = (d) => typeof d === 'string' && d.length >= 4 ? Number(d.slice(0, 4)) : null;
4
+ const posterOf = (p) => (typeof p === 'string' && p ? `${IMG}${p}` : null);
5
+ function candidate(r, kind) {
6
+ return {
7
+ tmdb_id: String(r['id']),
8
+ kind,
9
+ name: String(r['title'] ?? r['name'] ?? ''),
10
+ year: yearOf(r['release_date'] ?? r['first_air_date']),
11
+ poster: posterOf(r['poster_path']),
12
+ overview: String(r['overview'] ?? ''),
13
+ };
14
+ }
15
+ export function normalizeSearch(raw) {
16
+ const results = raw?.['results'] ?? [];
17
+ const out = [];
18
+ for (const r of results) {
19
+ const mt = r['media_type'];
20
+ if (mt === 'movie')
21
+ out.push(candidate(r, 'movie'));
22
+ else if (mt === 'tv')
23
+ out.push(candidate(r, 'series'));
24
+ }
25
+ return out;
26
+ }
27
+ export function normalizeDetails(raw, kind) {
28
+ const r = raw;
29
+ const base = candidate(r, kind);
30
+ const genres = (r['genres'] ?? []).map((g) => String(g['name'])).filter(Boolean);
31
+ const ext = r['external_ids'] ?? {};
32
+ const va = r['vote_average'];
33
+ return {
34
+ ...base,
35
+ genres,
36
+ runtime: typeof r['runtime'] === 'number' ? r['runtime'] : null,
37
+ tmdb_rating: typeof va === 'number' && va > 0 ? va : null,
38
+ imdb_id: (typeof ext['imdb_id'] === 'string' && ext['imdb_id']) || null,
39
+ seasons: typeof r['number_of_seasons'] === 'number' ? r['number_of_seasons'] : null,
40
+ episodes: typeof r['number_of_episodes'] === 'number' ? r['number_of_episodes'] : null,
41
+ link: `https://www.themoviedb.org/${kind === 'movie' ? 'movie' : 'tv'}/${base.tmdb_id}`,
42
+ };
43
+ }
44
+ export async function searchTitle(query, key, kind, fetchFn = fetch) {
45
+ const path = kind ? (kind === 'movie' ? 'search/movie' : 'search/tv') : 'search/multi';
46
+ const url = `${API}/${path}?api_key=${key}&query=${encodeURIComponent(query)}`;
47
+ const resp = await fetchFn(url);
48
+ if (!resp.ok)
49
+ throw new Error(`TMDB search returned ${resp.status}`);
50
+ const json = await resp.json();
51
+ if (kind) {
52
+ const mt = kind === 'movie' ? 'movie' : 'tv';
53
+ for (const r of json['results'] ?? [])
54
+ r['media_type'] = mt;
55
+ }
56
+ return normalizeSearch(json);
57
+ }
58
+ export async function getDetails(tmdbId, kind, key, fetchFn = fetch) {
59
+ const seg = kind === 'movie' ? 'movie' : 'tv';
60
+ const url = `${API}/${seg}/${tmdbId}?api_key=${key}&append_to_response=external_ids`;
61
+ const resp = await fetchFn(url);
62
+ if (!resp.ok)
63
+ throw new Error(`TMDB details returned ${resp.status}`);
64
+ return normalizeDetails(await resp.json(), kind);
65
+ }
@@ -0,0 +1,2 @@
1
+ declare const _default: import("@coffer-org/sdk/module").ModuleDef;
2
+ export default _default;
@@ -0,0 +1,63 @@
1
+ import { defineModule } from '@coffer-org/sdk/module';
2
+ import { field } from '@coffer-org/sdk/fields';
3
+ export default defineModule({
4
+ module: 'title',
5
+ vault: 'media',
6
+ label: 'media.title.label',
7
+ icon: 'lucide:clapperboard',
8
+ claude: 'A movie or series in the watchlist. name — the title. kind — movie/series. status — viewing intent ' +
9
+ '(want=added to the list, not in Jellyfin yet / unwatched=in Jellyfin, not watched / watching / watched / dropped). ' +
10
+ 'in_library, jellyfin_id and source are mirrored from Jellyfin — ' +
11
+ 'do not edit manually when source=jellyfin (sync will overwrite).',
12
+ views: {
13
+ table: ['kind', 'status', 'year', 'imdb_rating'],
14
+ groupBy: ['kind', 'status', 'genres', 'year', 'source'],
15
+ },
16
+ fields: [
17
+ field.title({ key: 'name', label: 'core.fields.name' }),
18
+ field.image({ key: 'poster', label: 'media.title.fields.poster' }),
19
+ field.text({ key: 'overview', label: 'media.title.fields.overview', rules: { max: 2000 } }),
20
+ field.select({
21
+ key: 'kind',
22
+ label: 'media.title.fields.kind',
23
+ options: [
24
+ { value: 'movie', title: 'media.title.options.kind.movie' },
25
+ { value: 'series', title: 'media.title.options.kind.series' },
26
+ ],
27
+ }),
28
+ field.int({ key: 'year', label: 'media.title.fields.year' }),
29
+ field.tags({ key: 'genres', label: 'media.title.fields.genres' }),
30
+ field.duration({ key: 'runtime', label: 'media.title.fields.runtime' }),
31
+ field.string({ key: 'tmdb_id', label: 'media.title.fields.tmdb_id', rules: { max: 32 } }),
32
+ field.string({ key: 'imdb_id', label: 'media.title.fields.imdb_id', rules: { max: 32 } }),
33
+ field.real({ key: 'tmdb_rating', label: 'media.title.fields.tmdb_rating' }),
34
+ field.real({ key: 'imdb_rating', label: 'media.title.fields.imdb_rating' }),
35
+ field.select({
36
+ key: 'status',
37
+ label: 'media.title.fields.status',
38
+ options: [
39
+ { value: 'want', title: 'media.title.options.status.want' },
40
+ { value: 'unwatched', title: 'media.title.options.status.unwatched' },
41
+ { value: 'watching', title: 'media.title.options.status.watching' },
42
+ { value: 'watched', title: 'media.title.options.status.watched' },
43
+ { value: 'dropped', title: 'media.title.options.status.dropped' },
44
+ ],
45
+ }),
46
+ field.date({ key: 'watched_date', label: 'media.title.fields.watched_date' }),
47
+ field.int({ key: 'seasons', label: 'media.title.fields.seasons', rules: { min: 0 } }),
48
+ field.int({ key: 'episodes', label: 'media.title.fields.episodes', rules: { min: 0 } }),
49
+ field.boolean({ key: 'in_library', label: 'media.title.fields.in_library' }),
50
+ field.string({ key: 'jellyfin_id', label: 'media.title.fields.jellyfin_id', rules: { max: 300 } }),
51
+ field.select({
52
+ key: 'source',
53
+ label: 'media.title.fields.source',
54
+ default: 'manual',
55
+ options: [
56
+ { value: 'manual', title: 'media.title.options.source.manual' },
57
+ { value: 'jellyfin', title: 'media.title.options.source.jellyfin' },
58
+ { value: 'agent', title: 'media.title.options.source.agent' },
59
+ ],
60
+ }),
61
+ field.url({ key: 'link', label: 'media.title.fields.link' }),
62
+ ],
63
+ });
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@coffer-org/plugin-media",
3
+ "version": "1.2.0",
4
+ "type": "module",
5
+ "engines": {
6
+ "node": ">=24"
7
+ },
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "./runtime": {
17
+ "types": "./dist/runtime/index.d.ts",
18
+ "default": "./dist/runtime/index.js"
19
+ }
20
+ },
21
+ "scripts": {
22
+ "build": "tsc -b tsconfig.build.json",
23
+ "prepack": "npm run build && node ../../scripts/swap-exports.mjs dist",
24
+ "postpack": "node ../../scripts/swap-exports.mjs src",
25
+ "test": "node --import tsx/esm --test 'src/runtime/*.test.ts'"
26
+ },
27
+ "dependencies": {
28
+ "@coffer-org/sdk": "^1.2.0",
29
+ "@coffer-org/server": "^1.2.0",
30
+ "zod": "^4.4.3"
31
+ }
32
+ }