yt2jellyfin 0.1.1 → 0.2.1

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.
package/README.md CHANGED
@@ -25,17 +25,19 @@ pnpm dev
25
25
  pnpm build && node dist/index.js
26
26
  ```
27
27
 
28
+ First run opens a setup screen with prefilled defaults (jellyfin url, upload dir, yt-dlp/ffmpeg paths); enter through the fields and it writes the config. A missing or broken config file shows the same screen again.
29
+
28
30
  Flow: `enter` adds a url, `enter` on an empty line downloads all, then each track opens a review box. `tab`/`shift+tab` move between fields, `enter` on the last field saves tags and uploads, `esc` skips. When the queue empties the library refresh is triggered.
29
31
 
30
32
  ## Config
31
33
 
32
- Created on first run at `~/.config/yt2jellyfin/config.json` (mode 600).
34
+ Asked in the TUI on first run, saved to `~/.config/yt2jellyfin/config.json` (mode 600). Values below are the defaults the setup screen prefills.
33
35
 
34
36
  ```json
35
37
  {
36
38
  "jellyfinUrl": "http://media.example.com:8096",
37
39
  "jellyfinApiKey": "",
38
- "jellyfinUploadDir": "/media/music/YouTube",
40
+ "jellyfinUploadDir": "/data/music/YouTube",
39
41
  "downloadDir": "~/Music/yt2jellyfin",
40
42
  "ytDlpPath": "yt-dlp",
41
43
  "ffmpegPath": "ffmpeg"
package/dist/app.js CHANGED
@@ -3,10 +3,18 @@ import { useRef, useState } from 'react';
3
3
  import { useEffect } from 'react';
4
4
  import { Box, Text, useApp, useInput } from 'ink';
5
5
  import TextInput from 'ink-text-input';
6
- import { loadConfig } from './config.js';
6
+ import { loadConfig, saveConfig } from './config.js';
7
7
  import { downloadTrack } from './ytdlp.js';
8
8
  import { applyTags } from './media.js';
9
9
  import { refreshLibrary, uploadToServer } from './jellyfin.js';
10
+ const SETUP_FIELDS = [
11
+ { key: 'jellyfinUrl', label: 'Jellyfin url' },
12
+ { key: 'jellyfinApiKey', label: 'API key', secret: true },
13
+ { key: 'jellyfinUploadDir', label: 'Server dir' },
14
+ { key: 'downloadDir', label: 'Download dir' },
15
+ { key: 'ytDlpPath', label: 'yt-dlp' },
16
+ { key: 'ffmpegPath', label: 'ffmpeg' },
17
+ ];
10
18
  const FIELD_ORDER = ['title', 'artist', 'album', 'genre'];
11
19
  const FIELD_LABELS = {
12
20
  title: 'Title',
@@ -58,9 +66,12 @@ function defaults(meta, file) {
58
66
  }
59
67
  export function App() {
60
68
  const loaded = useRef(loadConfig()).current;
61
- const cfg = loaded.config;
62
69
  const { exit } = useApp();
63
- const [phase, setPhase] = useState('urls');
70
+ const [config, setConfig] = useState(loaded.config);
71
+ const cfg = config;
72
+ const [phase, setPhase] = useState(loaded.needsSetup ? 'setup' : 'urls');
73
+ const [draft, setDraft] = useState(loaded.config);
74
+ const [setupIndex, setSetupIndex] = useState(0);
64
75
  const [items, setItems] = useState([]);
65
76
  const [input, setInput] = useState('');
66
77
  const [message, setMessage] = useState('');
@@ -94,6 +105,44 @@ export function App() {
94
105
  })));
95
106
  setPhase('downloading');
96
107
  }
108
+ function submitSetup(index) {
109
+ if (index < SETUP_FIELDS.length - 1) {
110
+ setSetupIndex(index + 1);
111
+ return;
112
+ }
113
+ const next = {
114
+ jellyfinUrl: draft.jellyfinUrl.trim().replace(/\/+$/, ''),
115
+ jellyfinApiKey: draft.jellyfinApiKey.trim(),
116
+ jellyfinUploadDir: draft.jellyfinUploadDir.trim(),
117
+ downloadDir: draft.downloadDir.trim(),
118
+ ytDlpPath: draft.ytDlpPath.trim() || 'yt-dlp',
119
+ ffmpegPath: draft.ffmpegPath.trim() || 'ffmpeg',
120
+ };
121
+ if (!/^https?:\/\//i.test(next.jellyfinUrl)) {
122
+ setMessage('jellyfin url must start with http:// or https://');
123
+ setSetupIndex(0);
124
+ return;
125
+ }
126
+ if (next.jellyfinApiKey.length === 0) {
127
+ setMessage('api key is required');
128
+ setSetupIndex(1);
129
+ return;
130
+ }
131
+ if (next.jellyfinUploadDir.length === 0 || next.downloadDir.length === 0) {
132
+ setMessage('upload and download dirs cannot be empty');
133
+ return;
134
+ }
135
+ try {
136
+ saveConfig(next);
137
+ setConfig(next);
138
+ setDraft(next);
139
+ setMessage(`config saved to ${loaded.path}`);
140
+ setPhase('urls');
141
+ }
142
+ catch (error) {
143
+ setMessage(`could not save config: ${errorMessage(error)}`);
144
+ }
145
+ }
97
146
  function submitUrls(value) {
98
147
  const tokens = value.split(/\s+/).filter(Boolean);
99
148
  const urls = tokens.filter((token) => /^https?:\/\//i.test(token));
@@ -226,6 +275,13 @@ export function App() {
226
275
  exit();
227
276
  return;
228
277
  }
278
+ if (phase === 'setup') {
279
+ if (key.tab) {
280
+ const delta = key.shift ? -1 : 1;
281
+ setSetupIndex((prev) => (prev + delta + SETUP_FIELDS.length) % SETUP_FIELDS.length);
282
+ }
283
+ return;
284
+ }
229
285
  if (phase !== 'review')
230
286
  return;
231
287
  if (key.tab) {
@@ -244,7 +300,9 @@ export function App() {
244
300
  const current = items[reviewIndex];
245
301
  const uploaded = items.filter((item) => item.status === 'uploaded').length;
246
302
  const failed = items.filter((item) => item.status === 'error').length;
247
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: "cyan", children: "yt2jellyfin" }), _jsxs(Text, { dimColor: true, children: [' ', cfg.jellyfinUrl, ' · ', cfg.jellyfinUploadDir] })] }), _jsxs(Text, { dimColor: true, children: ["config ", loaded.path, loaded.created ? ' (created, edit it for your server)' : ''] }), !cfg.jellyfinApiKey && (_jsx(Text, { color: "yellow", children: "jellyfin api key missing: set YTJ_JELLYFIN_API_KEY or edit the config file" })), phase === 'urls' ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [items.map((item, index) => (_jsxs(Text, { children: [index + 1, ". ", item.url] }, item.url))), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "green", children: "url " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: submitUrls, placeholder: "https://www.youtube.com/watch?v=..." })] }), _jsx(Text, { dimColor: true, children: "enter adds url \u00B7 enter on empty line downloads all \u00B7 ctrl+c quits" })] })) : (_jsx(Box, { flexDirection: "column", marginTop: 1, children: items.map((item, index) => (_jsxs(Text, { color: statusColor(item.status), children: [statusIcon(item.status), " ", index + 1, ". ", item.title || item.url, _jsxs(Text, { dimColor: true, children: [' ', statusText(item)] })] }, item.url))) })), phase === 'review' && current && current.status !== 'uploaded' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { dimColor: true, children: current.url }), FIELD_ORDER.map((name, index) => (_jsxs(Box, { children: [_jsx(Box, { width: 9, children: _jsx(Text, { color: index === fieldIndex ? 'green' : undefined, children: FIELD_LABELS[name] }) }), index === fieldIndex && current.status === 'downloaded' ? (_jsx(TextInput, { value: current[name], onChange: (value) => updateItem(reviewIndex, { [name]: value }), onSubmit: () => submitField(index) })) : (_jsx(Text, { dimColor: current[name].length === 0, children: current[name] || '-' }))] }, name))), _jsx(Text, { dimColor: true, children: current.status === 'uploading'
303
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: "cyan", children: "yt2jellyfin" }), _jsxs(Text, { dimColor: true, children: [' ', cfg.jellyfinUrl, ' · ', cfg.jellyfinUploadDir] })] }), _jsxs(Text, { dimColor: true, children: ["config ", loaded.path] }), phase !== 'setup' && !cfg.jellyfinApiKey && (_jsx(Text, { color: "yellow", children: "jellyfin api key missing: set YTJ_JELLYFIN_API_KEY or edit the config file" })), phase === 'setup' ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { children: "first run: check the jellyfin settings, defaults are prefilled" }), SETUP_FIELDS.map((field, index) => (_jsxs(Box, { children: [_jsx(Box, { width: 15, children: _jsx(Text, { color: index === setupIndex ? 'green' : undefined, children: field.label }) }), index === setupIndex ? (_jsx(TextInput, { value: draft[field.key], mask: field.secret ? '*' : undefined, onChange: (value) => setDraft((prev) => ({ ...prev, [field.key]: value })), onSubmit: () => submitSetup(index) })) : (_jsx(Text, { dimColor: draft[field.key].length === 0, children: field.secret && draft[field.key].length > 0
304
+ ? '*'.repeat(8)
305
+ : draft[field.key] || '-' }))] }, field.key))), _jsx(Text, { dimColor: true, children: "tab/shift+tab moves field \u00B7 enter saves on the last field" })] })) : phase === 'urls' ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [items.map((item, index) => (_jsxs(Text, { children: [index + 1, ". ", item.url] }, item.url))), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "green", children: "url " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: submitUrls, placeholder: "https://www.youtube.com/watch?v=..." })] }), _jsx(Text, { dimColor: true, children: "enter adds url \u00B7 enter on empty line downloads all \u00B7 ctrl+c quits" })] })) : (_jsx(Box, { flexDirection: "column", marginTop: 1, children: items.map((item, index) => (_jsxs(Text, { color: statusColor(item.status), children: [statusIcon(item.status), " ", index + 1, ". ", item.title || item.url, _jsxs(Text, { dimColor: true, children: [' ', statusText(item)] })] }, item.url))) })), phase === 'review' && current && current.status !== 'uploaded' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { dimColor: true, children: current.url }), FIELD_ORDER.map((name, index) => (_jsxs(Box, { children: [_jsx(Box, { width: 9, children: _jsx(Text, { color: index === fieldIndex ? 'green' : undefined, children: FIELD_LABELS[name] }) }), index === fieldIndex && current.status === 'downloaded' ? (_jsx(TextInput, { value: current[name], onChange: (value) => updateItem(reviewIndex, { [name]: value }), onSubmit: () => submitField(index) })) : (_jsx(Text, { dimColor: current[name].length === 0, children: current[name] || '-' }))] }, name))), _jsx(Text, { dimColor: true, children: current.status === 'uploading'
248
306
  ? 'uploading...'
249
307
  : busy
250
308
  ? 'refreshing library...'
package/dist/config.js CHANGED
@@ -5,7 +5,7 @@ const CONFIG_PATH = join(homedir(), '.config', 'yt2jellyfin', 'config.json');
5
5
  const DEFAULT_CONFIG = {
6
6
  jellyfinUrl: 'http://media.example.com:8096',
7
7
  jellyfinApiKey: '',
8
- jellyfinUploadDir: '/media/music/YouTube',
8
+ jellyfinUploadDir: '/data/music/YouTube',
9
9
  downloadDir: join(homedir(), 'Music', 'yt2jellyfin'),
10
10
  ytDlpPath: 'yt-dlp',
11
11
  ffmpegPath: 'ffmpeg',
@@ -18,15 +18,28 @@ const ENV_OVERRIDES = {
18
18
  YTJ_YTDLP: 'ytDlpPath',
19
19
  YTJ_FFMPEG: 'ffmpegPath',
20
20
  };
21
+ export function saveConfig(config) {
22
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
23
+ mkdirSync(config.downloadDir, { recursive: true });
24
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
25
+ chmodSync(CONFIG_PATH, 0o600);
26
+ }
21
27
  export function loadConfig() {
22
- let created = false;
28
+ let needsSetup = false;
29
+ let stored = {};
23
30
  if (!existsSync(CONFIG_PATH)) {
24
- mkdirSync(dirname(CONFIG_PATH), { recursive: true });
25
- writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + '\n');
26
- chmodSync(CONFIG_PATH, 0o600);
27
- created = true;
31
+ needsSetup = true;
32
+ }
33
+ else {
34
+ try {
35
+ stored = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
36
+ }
37
+ catch {
38
+ // broken/empty config: ask again with defaults instead of crashing
39
+ stored = {};
40
+ needsSetup = true;
41
+ }
28
42
  }
29
- const stored = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
30
43
  const config = { ...DEFAULT_CONFIG, ...stored };
31
44
  for (const [envKey, configKey] of Object.entries(ENV_OVERRIDES)) {
32
45
  const value = process.env[envKey];
@@ -34,6 +47,5 @@ export function loadConfig() {
34
47
  config[configKey] = value;
35
48
  }
36
49
  }
37
- mkdirSync(config.downloadDir, { recursive: true });
38
- return { config, path: CONFIG_PATH, created };
50
+ return { config, path: CONFIG_PATH, needsSetup };
39
51
  }
package/dist/jellyfin.js CHANGED
@@ -66,8 +66,9 @@ export async function uploadToServer(cfg, file) {
66
66
  body: form,
67
67
  });
68
68
  if (!response.ok) {
69
- const body = await response.text();
70
- throw new Error(`upload failed (${response.status}): ${body}`);
69
+ const body = (await response.text()).trim();
70
+ throw new Error(`upload failed (${response.status}): ${body || 'no body'} ` +
71
+ `(check that jellyfinUploadDir exists and is writable by the server)`);
71
72
  }
72
73
  }
73
74
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yt2jellyfin",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Paste a YouTube URL, download MP3, clean tags, upload to Jellyfin",
5
5
  "type": "module",
6
6
  "bin": {