yt2jellyfin 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 h4kbas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,49 @@
1
- # Temporary Holding Version
1
+ # yt2jellyfin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ TUI: paste YouTube urls, download as MP3, fix title/artist/album/genre, upload to the Jellyfin music library, refresh the library.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm i -g yt2jellyfin
9
+ yt2jellyfin
10
+ ```
11
+
12
+ ## Requirements
13
+
14
+ - node >= 22, pnpm
15
+ - `yt-dlp`, `ffmpeg` on PATH
16
+ - the [RemoteUpload plugin](https://github.com/grandguyjs/media-upload-plugin) on the Jellyfin server
17
+ - Jellyfin API key (Dashboard / API Keys, admin, needed for plugin upload + refresh)
18
+
19
+ ## From source
20
+
21
+ ```sh
22
+ pnpm install
23
+ pnpm dev
24
+ # or
25
+ pnpm build && node dist/index.js
26
+ ```
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
+
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.
31
+
32
+ ## Config
33
+
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.
35
+
36
+ ```json
37
+ {
38
+ "jellyfinUrl": "http://media.example.com:8096",
39
+ "jellyfinApiKey": "",
40
+ "jellyfinUploadDir": "/media/music/YouTube",
41
+ "downloadDir": "~/Music/yt2jellyfin",
42
+ "ytDlpPath": "yt-dlp",
43
+ "ffmpegPath": "ffmpeg"
44
+ }
45
+ ```
46
+
47
+ Env overrides: `YTJ_JELLYFIN_URL`, `YTJ_JELLYFIN_API_KEY`, `YTJ_JELLYFIN_UPLOAD_DIR`, `YTJ_DOWNLOAD_DIR`, `YTJ_YTDLP`, `YTJ_FFMPEG`.
48
+
49
+ Uploads are chunked through the plugin API into `jellyfinUploadDir` (server-side path inside the Jellyfin music library; the app sets the plugin's upload dir to it). Files never need ssh/scp.
package/dist/app.js ADDED
@@ -0,0 +1,310 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef, useState } from 'react';
3
+ import { useEffect } from 'react';
4
+ import { Box, Text, useApp, useInput } from 'ink';
5
+ import TextInput from 'ink-text-input';
6
+ import { loadConfig, saveConfig } from './config.js';
7
+ import { downloadTrack } from './ytdlp.js';
8
+ import { applyTags } from './media.js';
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
+ ];
18
+ const FIELD_ORDER = ['title', 'artist', 'album', 'genre'];
19
+ const FIELD_LABELS = {
20
+ title: 'Title',
21
+ artist: 'Artist',
22
+ album: 'Album',
23
+ genre: 'Genre',
24
+ };
25
+ function errorMessage(error) {
26
+ return error instanceof Error ? error.message : String(error);
27
+ }
28
+ function statusIcon(status) {
29
+ if (status === 'uploaded')
30
+ return '[ok]';
31
+ if (status === 'error')
32
+ return '[!!]';
33
+ if (status === 'downloading' || status === 'uploading')
34
+ return '[..]';
35
+ return '[ ]';
36
+ }
37
+ function statusColor(status) {
38
+ if (status === 'uploaded')
39
+ return 'green';
40
+ if (status === 'error')
41
+ return 'red';
42
+ if (status === 'downloading' || status === 'uploading')
43
+ return 'yellow';
44
+ return undefined;
45
+ }
46
+ function statusText(item) {
47
+ if (item.status === 'downloading') {
48
+ return item.progress ? `downloading ${item.progress}` : 'downloading';
49
+ }
50
+ if (item.status === 'error')
51
+ return `failed: ${item.error ?? 'unknown error'}`;
52
+ if (item.status === 'downloaded')
53
+ return 'ready to tag';
54
+ return item.status;
55
+ }
56
+ function defaults(meta, file) {
57
+ const base = file.split('/').pop() ?? file;
58
+ const title = (meta.track || meta.title || base.replace(/\.mp3$/i, '')).trim();
59
+ const artist = (meta.artist || meta.channel || meta.uploader || '').trim();
60
+ return {
61
+ title,
62
+ artist,
63
+ album: (meta.album ?? '').trim(),
64
+ genre: (meta.genre ?? '').trim(),
65
+ };
66
+ }
67
+ export function App() {
68
+ const loaded = useRef(loadConfig()).current;
69
+ const { exit } = useApp();
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);
75
+ const [items, setItems] = useState([]);
76
+ const [input, setInput] = useState('');
77
+ const [message, setMessage] = useState('');
78
+ const [reviewIndex, setReviewIndex] = useState(0);
79
+ const [fieldIndex, setFieldIndex] = useState(0);
80
+ const [busy, setBusy] = useState(false);
81
+ const urlsRef = useRef([]);
82
+ const busyRef = useRef(false);
83
+ // Mirror of the items state kept in a ref so async steps read fresh statuses
84
+ // without waiting for React to commit the last batched update.
85
+ const itemsRef = useRef(items);
86
+ function commit(next) {
87
+ itemsRef.current = next;
88
+ setItems(next);
89
+ }
90
+ function updateItem(index, patch) {
91
+ commit(itemsRef.current.map((item, i) => (i === index ? { ...item, ...patch } : item)));
92
+ }
93
+ function start() {
94
+ if (itemsRef.current.length === 0) {
95
+ setMessage('paste a youtube url first');
96
+ return;
97
+ }
98
+ urlsRef.current = itemsRef.current.map((item) => item.url);
99
+ setMessage('');
100
+ commit(itemsRef.current.map((item) => ({
101
+ ...item,
102
+ status: 'queued',
103
+ progress: '',
104
+ error: undefined,
105
+ })));
106
+ setPhase('downloading');
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
+ }
146
+ function submitUrls(value) {
147
+ const tokens = value.split(/\s+/).filter(Boolean);
148
+ const urls = tokens.filter((token) => /^https?:\/\//i.test(token));
149
+ if (urls.length === 0) {
150
+ if (tokens.length === 0 && itemsRef.current.length > 0) {
151
+ start();
152
+ }
153
+ else {
154
+ setMessage('not a url: paste a youtube link');
155
+ }
156
+ return;
157
+ }
158
+ const fresh = urls.filter((url) => !itemsRef.current.some((item) => item.url === url));
159
+ if (fresh.length === 0) {
160
+ setMessage('url already in the list');
161
+ return;
162
+ }
163
+ commit([
164
+ ...itemsRef.current,
165
+ ...fresh.map((url) => ({
166
+ url,
167
+ status: 'queued',
168
+ progress: '',
169
+ title: '',
170
+ artist: '',
171
+ album: '',
172
+ genre: '',
173
+ })),
174
+ ]);
175
+ setInput('');
176
+ setMessage('');
177
+ }
178
+ useEffect(() => {
179
+ if (phase !== 'downloading')
180
+ return;
181
+ let cancelled = false;
182
+ void (async () => {
183
+ for (let i = 0; i < urlsRef.current.length && !cancelled; i++) {
184
+ updateItem(i, { status: 'downloading', progress: '', error: undefined });
185
+ try {
186
+ const { file, meta } = await downloadTrack(cfg, urlsRef.current[i], (percent) => {
187
+ updateItem(i, { progress: percent });
188
+ });
189
+ updateItem(i, { status: 'downloaded', file, ...defaults(meta, file) });
190
+ }
191
+ catch (error) {
192
+ updateItem(i, { status: 'error', error: errorMessage(error) });
193
+ }
194
+ }
195
+ if (cancelled)
196
+ return;
197
+ const first = itemsRef.current.findIndex((item) => item.status === 'downloaded');
198
+ if (first === -1) {
199
+ setMessage('nothing downloaded');
200
+ setPhase('done');
201
+ return;
202
+ }
203
+ setReviewIndex(first);
204
+ setFieldIndex(0);
205
+ setPhase('review');
206
+ })();
207
+ return () => {
208
+ cancelled = true;
209
+ };
210
+ }, [phase]);
211
+ async function advance(from) {
212
+ const next = itemsRef.current.findIndex((item, i) => i > from && item.status === 'downloaded');
213
+ if (next !== -1) {
214
+ setReviewIndex(next);
215
+ setFieldIndex(0);
216
+ return;
217
+ }
218
+ try {
219
+ await refreshLibrary(cfg);
220
+ setMessage('upload done, library scan started');
221
+ }
222
+ catch (error) {
223
+ setMessage(`upload done, refresh failed: ${errorMessage(error)}`);
224
+ }
225
+ setPhase('done');
226
+ }
227
+ async function acceptAndUpload() {
228
+ if (busyRef.current)
229
+ return;
230
+ const index = reviewIndex;
231
+ const item = itemsRef.current[index];
232
+ if (!item || item.status !== 'downloaded' || !item.file)
233
+ return;
234
+ const values = {
235
+ title: item.title.trim(),
236
+ artist: item.artist.trim(),
237
+ album: item.album.trim(),
238
+ genre: item.genre.trim(),
239
+ };
240
+ if (values.title.length === 0) {
241
+ setMessage('title cannot be empty');
242
+ return;
243
+ }
244
+ busyRef.current = true;
245
+ setBusy(true);
246
+ setMessage('');
247
+ updateItem(index, { status: 'uploading' });
248
+ try {
249
+ const tagged = await applyTags(cfg, item.file, values);
250
+ updateItem(index, { file: tagged });
251
+ await uploadToServer(cfg, tagged);
252
+ updateItem(index, { status: 'uploaded' });
253
+ await advance(index);
254
+ }
255
+ catch (error) {
256
+ updateItem(index, { status: 'error', error: errorMessage(error) });
257
+ await advance(index);
258
+ }
259
+ finally {
260
+ busyRef.current = false;
261
+ setBusy(false);
262
+ }
263
+ }
264
+ function submitField(index) {
265
+ if (index < FIELD_ORDER.length - 1) {
266
+ setFieldIndex(index + 1);
267
+ }
268
+ else {
269
+ void acceptAndUpload();
270
+ }
271
+ }
272
+ useInput((_input, key) => {
273
+ if (phase === 'done') {
274
+ if (key.return || key.escape)
275
+ exit();
276
+ return;
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
+ }
285
+ if (phase !== 'review')
286
+ return;
287
+ if (key.tab) {
288
+ const delta = key.shift ? -1 : 1;
289
+ setFieldIndex((prev) => (prev + delta + FIELD_ORDER.length) % FIELD_ORDER.length);
290
+ return;
291
+ }
292
+ if (key.escape) {
293
+ const item = itemsRef.current[reviewIndex];
294
+ if (!item || item.status !== 'downloaded')
295
+ return;
296
+ updateItem(reviewIndex, { status: 'error', error: 'skipped' });
297
+ void advance(reviewIndex);
298
+ }
299
+ });
300
+ const current = items[reviewIndex];
301
+ const uploaded = items.filter((item) => item.status === 'uploaded').length;
302
+ const failed = items.filter((item) => item.status === 'error').length;
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'
306
+ ? 'uploading...'
307
+ : busy
308
+ ? 'refreshing library...'
309
+ : 'tab/shift+tab moves field · enter saves and uploads · esc skips track' })] })), phase === 'done' && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: ["uploaded ", uploaded, " \u00B7 failed ", failed] }) })), message.length > 0 && _jsx(Text, { color: "yellow", children: message }), phase === 'done' && _jsx(Text, { dimColor: true, children: "enter exits" })] }));
310
+ }
package/dist/config.js ADDED
@@ -0,0 +1,51 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ const CONFIG_PATH = join(homedir(), '.config', 'yt2jellyfin', 'config.json');
5
+ const DEFAULT_CONFIG = {
6
+ jellyfinUrl: 'http://media.example.com:8096',
7
+ jellyfinApiKey: '',
8
+ jellyfinUploadDir: '/media/music/YouTube',
9
+ downloadDir: join(homedir(), 'Music', 'yt2jellyfin'),
10
+ ytDlpPath: 'yt-dlp',
11
+ ffmpegPath: 'ffmpeg',
12
+ };
13
+ const ENV_OVERRIDES = {
14
+ YTJ_JELLYFIN_URL: 'jellyfinUrl',
15
+ YTJ_JELLYFIN_API_KEY: 'jellyfinApiKey',
16
+ YTJ_JELLYFIN_UPLOAD_DIR: 'jellyfinUploadDir',
17
+ YTJ_DOWNLOAD_DIR: 'downloadDir',
18
+ YTJ_YTDLP: 'ytDlpPath',
19
+ YTJ_FFMPEG: 'ffmpegPath',
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
+ }
27
+ export function loadConfig() {
28
+ let needsSetup = false;
29
+ let stored = {};
30
+ if (!existsSync(CONFIG_PATH)) {
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
+ }
42
+ }
43
+ const config = { ...DEFAULT_CONFIG, ...stored };
44
+ for (const [envKey, configKey] of Object.entries(ENV_OVERRIDES)) {
45
+ const value = process.env[envKey];
46
+ if (typeof value === 'string' && value.length > 0) {
47
+ config[configKey] = value;
48
+ }
49
+ }
50
+ return { config, path: CONFIG_PATH, needsSetup };
51
+ }
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { render } from 'ink';
4
+ import { App } from './app.js';
5
+ render(_jsx(App, {}));
@@ -0,0 +1,85 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
3
+ // RemoteUpload plugin: https://github.com/grandguyjs/media-upload-plugin
4
+ const REMOTE_UPLOAD_PLUGIN_ID = 'b5a2d6b3-c5f4-4f88-aed4-5f3e9877d0a6';
5
+ // The plugin's own client uploads 28MB chunks; keep the same size so a reverse
6
+ // proxy sized for it (30MB body) also works here.
7
+ const UPLOAD_CHUNK_SIZE = 28 * 1024 * 1024;
8
+ function apiUrl(cfg, path) {
9
+ return `${cfg.jellyfinUrl.replace(/\/+$/, '')}${path}`;
10
+ }
11
+ function authHeaders(cfg) {
12
+ return { 'X-Emby-Token': cfg.jellyfinApiKey };
13
+ }
14
+ function requireApiKey(cfg) {
15
+ if (!cfg.jellyfinApiKey) {
16
+ throw new Error('Jellyfin API key is empty: set YTJ_JELLYFIN_API_KEY or add jellyfinApiKey to the config file');
17
+ }
18
+ }
19
+ // The plugin always writes into one configured server directory, so point it at
20
+ // the music library before uploading (first run and after a dir change).
21
+ async function ensureUploadDir(cfg) {
22
+ if (cfg.jellyfinUploadDir.trim().length === 0) {
23
+ throw new Error('Jellyfin upload dir is empty: set YTJ_JELLYFIN_UPLOAD_DIR or add jellyfinUploadDir to the config file');
24
+ }
25
+ const url = apiUrl(cfg, `/Plugins/${REMOTE_UPLOAD_PLUGIN_ID}/Configuration`);
26
+ const current = await fetch(url, { headers: authHeaders(cfg) });
27
+ if (!current.ok) {
28
+ throw new Error(`RemoteUpload plugin config read failed (${current.status}): is the plugin installed?`);
29
+ }
30
+ const configuration = (await current.json());
31
+ if (configuration.uploaddir === cfg.jellyfinUploadDir)
32
+ return;
33
+ const dirs = Array.isArray(configuration.uploaddirs) ? configuration.uploaddirs : [];
34
+ if (!dirs.includes(cfg.jellyfinUploadDir))
35
+ dirs.push(cfg.jellyfinUploadDir);
36
+ const response = await fetch(url, {
37
+ method: 'POST',
38
+ headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
39
+ body: JSON.stringify({
40
+ ...configuration,
41
+ uploaddir: cfg.jellyfinUploadDir,
42
+ uploaddirs: dirs,
43
+ }),
44
+ });
45
+ if (!response.ok) {
46
+ const body = await response.text();
47
+ throw new Error(`RemoteUpload dir update failed (${response.status}): ${body}`);
48
+ }
49
+ }
50
+ export async function uploadToServer(cfg, file) {
51
+ requireApiKey(cfg);
52
+ await ensureUploadDir(cfg);
53
+ const name = basename(file);
54
+ const data = await readFile(file);
55
+ const totalChunks = Math.max(1, Math.ceil(data.byteLength / UPLOAD_CHUNK_SIZE));
56
+ for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
57
+ const start = chunkIndex * UPLOAD_CHUNK_SIZE;
58
+ const chunk = data.subarray(start, start + UPLOAD_CHUNK_SIZE);
59
+ const form = new FormData();
60
+ form.append('file', new Blob([chunk], { type: 'audio/mpeg' }), name);
61
+ form.append('chunkIndex', String(chunkIndex));
62
+ form.append('totalChunks', String(totalChunks));
63
+ const response = await fetch(apiUrl(cfg, '/mediaupload/upload'), {
64
+ method: 'POST',
65
+ headers: authHeaders(cfg),
66
+ body: form,
67
+ });
68
+ if (!response.ok) {
69
+ const body = await response.text();
70
+ throw new Error(`upload failed (${response.status}): ${body}`);
71
+ }
72
+ }
73
+ }
74
+ export async function refreshLibrary(cfg) {
75
+ requireApiKey(cfg);
76
+ const url = apiUrl(cfg, '/Library/Refresh');
77
+ const response = await fetch(url, {
78
+ method: 'POST',
79
+ headers: authHeaders(cfg),
80
+ });
81
+ if (!response.ok) {
82
+ const body = await response.text();
83
+ throw new Error(`jellyfin refresh failed (${response.status}): ${body}`);
84
+ }
85
+ }
package/dist/media.js ADDED
@@ -0,0 +1,52 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { rename, rm } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ const execFileAsync = promisify(execFile);
6
+ export function sanitizeFileName(name) {
7
+ const cleaned = name
8
+ .replace(/[\\/:*?"<>|]/g, ' ')
9
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
10
+ .replace(/\s+/g, ' ')
11
+ .trim()
12
+ .replace(/[. ]+$/, '');
13
+ return cleaned.slice(0, 150).trim() || 'track';
14
+ }
15
+ export function trackFileName(title, artist) {
16
+ const safeTitle = sanitizeFileName(title);
17
+ const safeArtist = artist.trim().length > 0 ? sanitizeFileName(artist) : '';
18
+ return safeArtist.length > 0 ? `${safeArtist} - ${safeTitle}.mp3` : `${safeTitle}.mp3`;
19
+ }
20
+ // Rewrites the mp3 tags so Jellyfin does not index YouTube junk (video title as
21
+ // title, channel name as artist, description/purl as extra metadata).
22
+ export async function applyTags(cfg, file, values) {
23
+ const target = join(dirname(file), trackFileName(values.title, values.artist));
24
+ const temp = `${file}.tagged.mp3`;
25
+ const args = [
26
+ '-y',
27
+ '-i',
28
+ file,
29
+ '-map',
30
+ '0',
31
+ '-c',
32
+ 'copy',
33
+ '-map_metadata',
34
+ '-1',
35
+ '-metadata',
36
+ `title=${values.title}`,
37
+ '-metadata',
38
+ `artist=${values.artist}`,
39
+ ];
40
+ if (values.album.trim().length > 0) {
41
+ args.push('-metadata', `album=${values.album}`);
42
+ }
43
+ if (values.genre.trim().length > 0) {
44
+ args.push('-metadata', `genre=${values.genre}`);
45
+ }
46
+ await execFileAsync(cfg.ffmpegPath, [...args, temp]);
47
+ if (resolve(target) !== resolve(file)) {
48
+ await rm(file, { force: true });
49
+ }
50
+ await rename(temp, target);
51
+ return target;
52
+ }
package/dist/ytdlp.js ADDED
@@ -0,0 +1,84 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import { createInterface } from 'node:readline';
5
+ const META_PREFIX = '__META__';
6
+ const FILE_PREFIX = '__FILE__';
7
+ const PROGRESS_PREFIX = '__PROG__';
8
+ function expandHome(file) {
9
+ if (file === '~')
10
+ return homedir();
11
+ if (file.startsWith('~/'))
12
+ return join(homedir(), file.slice(2));
13
+ return file;
14
+ }
15
+ export function downloadTrack(cfg, url, onProgress) {
16
+ return new Promise((resolvePromise, rejectPromise) => {
17
+ const args = [
18
+ '--no-playlist',
19
+ '--newline',
20
+ '--no-warnings',
21
+ '--print',
22
+ `before_dl:${META_PREFIX}%(.{id,title,artist,album,genre,track,uploader,channel})j`,
23
+ '--progress-template',
24
+ `download:${PROGRESS_PREFIX}%(progress._percent_str)s`,
25
+ '--print',
26
+ `after_move:${FILE_PREFIX}%(filepath)s`,
27
+ '-x',
28
+ '--audio-format',
29
+ 'mp3',
30
+ '--audio-quality',
31
+ '0',
32
+ '--embed-metadata',
33
+ '--embed-thumbnail',
34
+ '-o',
35
+ '%(id)s.%(ext)s',
36
+ url,
37
+ ];
38
+ const child = spawn(cfg.ytDlpPath, args, {
39
+ cwd: cfg.downloadDir,
40
+ stdio: ['ignore', 'pipe', 'pipe'],
41
+ });
42
+ const meta = {};
43
+ let file = '';
44
+ let stderr = '';
45
+ const stdout = createInterface({ input: child.stdout });
46
+ stdout.on('line', (line) => {
47
+ if (line.startsWith(META_PREFIX)) {
48
+ try {
49
+ Object.assign(meta, JSON.parse(line.slice(META_PREFIX.length)));
50
+ }
51
+ catch {
52
+ // keep going with whatever yt-dlp gave us
53
+ }
54
+ return;
55
+ }
56
+ if (line.startsWith(FILE_PREFIX)) {
57
+ file = line.slice(FILE_PREFIX.length).trim();
58
+ return;
59
+ }
60
+ if (line.startsWith(PROGRESS_PREFIX)) {
61
+ onProgress(line.slice(PROGRESS_PREFIX.length).trim());
62
+ }
63
+ });
64
+ child.stderr.on('data', (chunk) => {
65
+ stderr = (stderr + chunk.toString()).slice(-4000);
66
+ });
67
+ child.on('error', (error) => {
68
+ rejectPromise(new Error(`failed to run ${cfg.ytDlpPath}: ${error.message}`));
69
+ });
70
+ child.on('close', (code) => {
71
+ if (code !== 0) {
72
+ rejectPromise(new Error(stderr.trim() || `yt-dlp exited with code ${code}`));
73
+ return;
74
+ }
75
+ const guessed = meta.id ? resolve(cfg.downloadDir, `${meta.id}.mp3`) : '';
76
+ const result = file ? resolve(expandHome(file)) : guessed;
77
+ if (!result) {
78
+ rejectPromise(new Error('yt-dlp finished but no output file was reported'));
79
+ return;
80
+ }
81
+ resolvePromise({ file: result, meta });
82
+ });
83
+ });
84
+ }
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "yt2jellyfin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Paste a YouTube URL, download MP3, clean tags, upload to Jellyfin",
5
+ "type": "module",
6
+ "bin": {
7
+ "yt2jellyfin": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "dev": "tsx src/index.tsx",
11
+ "build": "tsc",
12
+ "start": "node dist/index.js",
13
+ "typecheck": "tsc --noEmit",
14
+ "prepublishOnly": "npm run build"
15
+ },
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "license": "MIT",
20
+ "dependencies": {
21
+ "ink": "^8.0.0",
22
+ "ink-text-input": "^6.0.0",
23
+ "react": "^19.3.1"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^26.6.6",
27
+ "@types/react": "^19.3.0",
28
+ "tsx": "^4.23.15",
29
+ "typescript": "^5.9.3"
30
+ },
31
+ "author": "h4kbas",
32
+ "keywords": [
33
+ "jellyfin",
34
+ "youtube",
35
+ "yt-dlp",
36
+ "mp3",
37
+ "tui",
38
+ "ink",
39
+ "cli"
40
+ ],
41
+ "files": [
42
+ "dist"
43
+ ]
44
+ }