yt2jellyfin 0.1.1 → 0.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.
- package/README.md +3 -1
- package/dist/app.js +62 -4
- package/dist/config.js +20 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,11 +25,13 @@ 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
|
-
|
|
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
|
{
|
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 [
|
|
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
|
|
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
|
@@ -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
|
|
28
|
+
let needsSetup = false;
|
|
29
|
+
let stored = {};
|
|
23
30
|
if (!existsSync(CONFIG_PATH)) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
38
|
-
return { config, path: CONFIG_PATH, created };
|
|
50
|
+
return { config, path: CONFIG_PATH, needsSetup };
|
|
39
51
|
}
|