yt2jellyfin 0.2.1 → 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 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
+ ![paste youtube urls](screenshots/1-urls.png)
10
+
11
+ Downloads run one at a time with yt-dlp progress:
12
+
13
+ ![downloading with progress](screenshots/2-downloading.png)
14
+
15
+ Each track opens a tag box, prefilled from yt-dlp metadata:
16
+
17
+ ![review and fix tags](screenshots/3-review.png)
18
+
19
+ `enter` on the last field tags and uploads through the plugin:
20
+
21
+ ![uploading](screenshots/4-uploading.png)
22
+
23
+ When the queue empties the library refresh is triggered:
24
+
25
+ ![done, library scan started](screenshots/5-done.png)
26
+
5
27
  ## Install
6
28
 
7
29
  ```sh
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
- setMessage('upload done, library scan started');
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/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
  }
@@ -84,3 +112,68 @@ export async function refreshLibrary(cfg) {
84
112
  throw new Error(`jellyfin refresh failed (${response.status}): ${body}`);
85
113
  }
86
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',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yt2jellyfin",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Paste a YouTube URL, download MP3, clean tags, upload to Jellyfin",
5
5
  "type": "module",
6
6
  "bin": {