yt2jellyfin 0.2.0 → 0.3.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 +23 -1
- package/dist/app.js +8 -2
- package/dist/config.js +1 -1
- package/dist/jellyfin.js +96 -2
- package/dist/ytdlp.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
TUI: paste YouTube urls, download as MP3, fix title/artist/album/genre, upload to the Jellyfin music library, refresh the library.
|
|
4
4
|
|
|
5
|
+
## Screenshots
|
|
6
|
+
|
|
7
|
+
Paste the urls:
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
Downloads run one at a time with yt-dlp progress:
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
Each track opens a tag box, prefilled from yt-dlp metadata:
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
`enter` on the last field tags and uploads through the plugin:
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+
When the queue empties the library refresh is triggered:
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
5
27
|
## Install
|
|
6
28
|
|
|
7
29
|
```sh
|
|
@@ -37,7 +59,7 @@ Asked in the TUI on first run, saved to `~/.config/yt2jellyfin/config.json` (mod
|
|
|
37
59
|
{
|
|
38
60
|
"jellyfinUrl": "http://media.example.com:8096",
|
|
39
61
|
"jellyfinApiKey": "",
|
|
40
|
-
"jellyfinUploadDir": "/
|
|
62
|
+
"jellyfinUploadDir": "/data/music/YouTube",
|
|
41
63
|
"downloadDir": "~/Music/yt2jellyfin",
|
|
42
64
|
"ytDlpPath": "yt-dlp",
|
|
43
65
|
"ffmpegPath": "ffmpeg"
|
package/dist/app.js
CHANGED
|
@@ -6,7 +6,7 @@ import TextInput from 'ink-text-input';
|
|
|
6
6
|
import { loadConfig, saveConfig } from './config.js';
|
|
7
7
|
import { downloadTrack } from './ytdlp.js';
|
|
8
8
|
import { applyTags } from './media.js';
|
|
9
|
-
import { refreshLibrary, uploadToServer } from './jellyfin.js';
|
|
9
|
+
import { refreshLibrary, tagUploadedFiles, uploadToServer } from './jellyfin.js';
|
|
10
10
|
const SETUP_FIELDS = [
|
|
11
11
|
{ key: 'jellyfinUrl', label: 'Jellyfin url' },
|
|
12
12
|
{ key: 'jellyfinApiKey', label: 'API key', secret: true },
|
|
@@ -217,7 +217,13 @@ export function App() {
|
|
|
217
217
|
}
|
|
218
218
|
try {
|
|
219
219
|
await refreshLibrary(cfg);
|
|
220
|
-
|
|
220
|
+
const tracks = itemsRef.current
|
|
221
|
+
.filter((item) => item.status === 'uploaded' && item.file)
|
|
222
|
+
.map((item) => ({ file: item.file, title: item.title }));
|
|
223
|
+
const { missed } = await tagUploadedFiles(cfg, tracks);
|
|
224
|
+
setMessage(missed > 0
|
|
225
|
+
? `upload done, library scan started, YouTube tag missed on ${missed}`
|
|
226
|
+
: 'upload done, YouTube tag added, library scan started');
|
|
221
227
|
}
|
|
222
228
|
catch (error) {
|
|
223
229
|
setMessage(`upload done, refresh failed: ${errorMessage(error)}`);
|
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: '/
|
|
8
|
+
jellyfinUploadDir: '/data/music/YouTube',
|
|
9
9
|
downloadDir: join(homedir(), 'Music', 'yt2jellyfin'),
|
|
10
10
|
ytDlpPath: 'yt-dlp',
|
|
11
11
|
ffmpegPath: 'ffmpeg',
|
package/dist/jellyfin.js
CHANGED
|
@@ -5,6 +5,34 @@ const REMOTE_UPLOAD_PLUGIN_ID = 'b5a2d6b3-c5f4-4f88-aed4-5f3e9877d0a6';
|
|
|
5
5
|
// The plugin's own client uploads 28MB chunks; keep the same size so a reverse
|
|
6
6
|
// proxy sized for it (30MB body) also works here.
|
|
7
7
|
const UPLOAD_CHUNK_SIZE = 28 * 1024 * 1024;
|
|
8
|
+
// Marks tracks that came in through the YouTube flow so they can be filtered
|
|
9
|
+
// by tag in Jellyfin. Written after the scan indexes the uploaded file.
|
|
10
|
+
const DEFAULT_TAG = 'YouTube';
|
|
11
|
+
const TAG_TIMEOUT_MS = 60_000;
|
|
12
|
+
const TAG_POLL_MS = 2_000;
|
|
13
|
+
// UpdateItem needs ProviderIds present, the server throws on a null map. Ask for
|
|
14
|
+
// every editable field so posting the item back keeps the metadata it already has.
|
|
15
|
+
const ITEM_FIELDS = [
|
|
16
|
+
'Path',
|
|
17
|
+
'Tags',
|
|
18
|
+
'Genres',
|
|
19
|
+
'Overview',
|
|
20
|
+
'ProviderIds',
|
|
21
|
+
'Studios',
|
|
22
|
+
'Taglines',
|
|
23
|
+
'People',
|
|
24
|
+
'ProductionYear',
|
|
25
|
+
'PremiereDate',
|
|
26
|
+
'EndDate',
|
|
27
|
+
'OfficialRating',
|
|
28
|
+
'CustomRating',
|
|
29
|
+
'ForcedSortName',
|
|
30
|
+
'OriginalTitle',
|
|
31
|
+
'IndexNumber',
|
|
32
|
+
'ParentIndexNumber',
|
|
33
|
+
'CriticRating',
|
|
34
|
+
'CommunityRating',
|
|
35
|
+
].join(',');
|
|
8
36
|
function apiUrl(cfg, path) {
|
|
9
37
|
return `${cfg.jellyfinUrl.replace(/\/+$/, '')}${path}`;
|
|
10
38
|
}
|
|
@@ -66,8 +94,9 @@ export async function uploadToServer(cfg, file) {
|
|
|
66
94
|
body: form,
|
|
67
95
|
});
|
|
68
96
|
if (!response.ok) {
|
|
69
|
-
const body = await response.text();
|
|
70
|
-
throw new Error(`upload failed (${response.status}): ${body}`
|
|
97
|
+
const body = (await response.text()).trim();
|
|
98
|
+
throw new Error(`upload failed (${response.status}): ${body || 'no body'} ` +
|
|
99
|
+
`(check that jellyfinUploadDir exists and is writable by the server)`);
|
|
71
100
|
}
|
|
72
101
|
}
|
|
73
102
|
}
|
|
@@ -83,3 +112,68 @@ export async function refreshLibrary(cfg) {
|
|
|
83
112
|
throw new Error(`jellyfin refresh failed (${response.status}): ${body}`);
|
|
84
113
|
}
|
|
85
114
|
}
|
|
115
|
+
function sleep(ms) {
|
|
116
|
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
117
|
+
}
|
|
118
|
+
// Uploads land on the server as <uploadDir>/<file name>; match on that instead of
|
|
119
|
+
// the title, which the user may have edited in the review box. The title is only
|
|
120
|
+
// used for the search: the file name has the artist prefixed and search is a
|
|
121
|
+
// substring match on the item name.
|
|
122
|
+
async function findUploadedItem(cfg, file, title) {
|
|
123
|
+
const name = basename(file);
|
|
124
|
+
const params = new URLSearchParams({
|
|
125
|
+
Recursive: 'true',
|
|
126
|
+
IncludeItemTypes: 'Audio',
|
|
127
|
+
SearchTerm: title.trim() || name.replace(/\.mp3$/i, ''),
|
|
128
|
+
Fields: ITEM_FIELDS,
|
|
129
|
+
Limit: '20',
|
|
130
|
+
});
|
|
131
|
+
const response = await fetch(apiUrl(cfg, `/Items?${params}`), { headers: authHeaders(cfg) });
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
throw new Error(`jellyfin item lookup failed (${response.status})`);
|
|
134
|
+
}
|
|
135
|
+
const body = (await response.json());
|
|
136
|
+
return (body.Items ?? []).find((item) => basename(item.Path ?? '') === name) ?? null;
|
|
137
|
+
}
|
|
138
|
+
async function addDefaultTag(cfg, item) {
|
|
139
|
+
const tags = Array.isArray(item.Tags) ? item.Tags : [];
|
|
140
|
+
if (tags.includes(DEFAULT_TAG))
|
|
141
|
+
return false;
|
|
142
|
+
const body = {
|
|
143
|
+
...item,
|
|
144
|
+
Tags: [...tags, DEFAULT_TAG],
|
|
145
|
+
ProviderIds: item.ProviderIds ?? {},
|
|
146
|
+
};
|
|
147
|
+
const response = await fetch(apiUrl(cfg, `/Items/${item.Id}`), {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
|
|
150
|
+
body: JSON.stringify(body),
|
|
151
|
+
});
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
const message = (await response.text()).trim();
|
|
154
|
+
throw new Error(`jellyfin tag update failed (${response.status}): ${message || 'no body'}`);
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
// The scan runs in the background, so the item can take a while to show up.
|
|
159
|
+
export async function tagUploadedFiles(cfg, tracks) {
|
|
160
|
+
requireApiKey(cfg);
|
|
161
|
+
const pending = [...tracks];
|
|
162
|
+
const deadline = Date.now() + TAG_TIMEOUT_MS;
|
|
163
|
+
let tagged = 0;
|
|
164
|
+
while (pending.length > 0 && Date.now() < deadline) {
|
|
165
|
+
for (let index = pending.length - 1; index >= 0; index--) {
|
|
166
|
+
const item = await findUploadedItem(cfg, pending[index].file, pending[index].title);
|
|
167
|
+
if (!item)
|
|
168
|
+
continue;
|
|
169
|
+
const added = await addDefaultTag(cfg, item);
|
|
170
|
+
pending.splice(index, 1);
|
|
171
|
+
if (added)
|
|
172
|
+
tagged += 1;
|
|
173
|
+
}
|
|
174
|
+
if (pending.length === 0)
|
|
175
|
+
break;
|
|
176
|
+
await sleep(TAG_POLL_MS);
|
|
177
|
+
}
|
|
178
|
+
return { tagged, missed: pending.length };
|
|
179
|
+
}
|
package/dist/ytdlp.js
CHANGED
|
@@ -18,6 +18,8 @@ export function downloadTrack(cfg, url, onProgress) {
|
|
|
18
18
|
'--no-playlist',
|
|
19
19
|
'--newline',
|
|
20
20
|
'--no-warnings',
|
|
21
|
+
// --print turns progress off, force it back so the TUI can show percentages
|
|
22
|
+
'--progress',
|
|
21
23
|
'--print',
|
|
22
24
|
`before_dl:${META_PREFIX}%(.{id,title,artist,album,genre,track,uploader,channel})j`,
|
|
23
25
|
'--progress-template',
|