crates
fin
fin-config
fin-jellyfin
fin-player
fin-tui
···
1
1
+
# This file is automatically @generated by Cargo.
2
2
+
# It is not intended for manual editing.
3
3
+
version = 4
4
4
+
5
5
+
[[package]]
6
6
+
name = "aho-corasick"
7
7
+
version = "1.1.4"
8
8
+
source = "registry+https://github.com/rust-lang/crates.io-index"
9
9
+
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
10
10
+
dependencies = [
11
11
+
"memchr",
12
12
+
]
13
13
+
14
14
+
[[package]]
15
15
+
name = "allocator-api2"
16
16
+
version = "0.2.21"
17
17
+
source = "registry+https://github.com/rust-lang/crates.io-index"
18
18
+
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
19
19
+
20
20
+
[[package]]
21
21
+
name = "android_system_properties"
22
22
+
version = "0.1.5"
23
23
+
source = "registry+https://github.com/rust-lang/crates.io-index"
24
24
+
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
25
25
+
dependencies = [
26
26
+
"libc",
27
27
+
]
28
28
+
29
29
+
[[package]]
30
30
+
name = "anstream"
31
31
+
version = "1.0.0"
32
32
+
source = "registry+https://github.com/rust-lang/crates.io-index"
33
33
+
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
34
34
+
dependencies = [
35
35
+
"anstyle",
36
36
+
"anstyle-parse",
37
37
+
"anstyle-query",
38
38
+
"anstyle-wincon",
39
39
+
"colorchoice",
40
40
+
"is_terminal_polyfill",
41
41
+
"utf8parse",
42
42
+
]
43
43
+
44
44
+
[[package]]
45
45
+
name = "anstyle"
46
46
+
version = "1.0.14"
47
47
+
source = "registry+https://github.com/rust-lang/crates.io-index"
48
48
+
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
49
49
+
50
50
+
[[package]]
51
51
+
name = "anstyle-parse"
52
52
+
version = "1.0.0"
53
53
+
source = "registry+https://github.com/rust-lang/crates.io-index"
54
54
+
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
55
55
+
dependencies = [
56
56
+
"utf8parse",
57
57
+
]
58
58
+
59
59
+
[[package]]
60
60
+
name = "anstyle-query"
61
61
+
version = "1.1.5"
62
62
+
source = "registry+https://github.com/rust-lang/crates.io-index"
63
63
+
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
64
64
+
dependencies = [
65
65
+
"windows-sys 0.61.2",
66
66
+
]
67
67
+
68
68
+
[[package]]
69
69
+
name = "anstyle-wincon"
70
70
+
version = "3.0.11"
71
71
+
source = "registry+https://github.com/rust-lang/crates.io-index"
72
72
+
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
73
73
+
dependencies = [
74
74
+
"anstyle",
75
75
+
"once_cell_polyfill",
76
76
+
"windows-sys 0.61.2",
77
77
+
]
78
78
+
79
79
+
[[package]]
80
80
+
name = "anyhow"
81
81
+
version = "1.0.103"
82
82
+
source = "registry+https://github.com/rust-lang/crates.io-index"
83
83
+
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
84
84
+
85
85
+
[[package]]
86
86
+
name = "async-trait"
87
87
+
version = "0.1.89"
88
88
+
source = "registry+https://github.com/rust-lang/crates.io-index"
89
89
+
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
90
90
+
dependencies = [
91
91
+
"proc-macro2",
92
92
+
"quote",
93
93
+
"syn",
94
94
+
]
95
95
+
96
96
+
[[package]]
97
97
+
name = "atomic-waker"
98
98
+
version = "1.1.2"
99
99
+
source = "registry+https://github.com/rust-lang/crates.io-index"
100
100
+
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
101
101
+
102
102
+
[[package]]
103
103
+
name = "autocfg"
104
104
+
version = "1.5.1"
105
105
+
source = "registry+https://github.com/rust-lang/crates.io-index"
106
106
+
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
107
107
+
108
108
+
[[package]]
109
109
+
name = "aws-lc-rs"
110
110
+
version = "1.17.1"
111
111
+
source = "registry+https://github.com/rust-lang/crates.io-index"
112
112
+
checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad"
113
113
+
dependencies = [
114
114
+
"aws-lc-sys",
115
115
+
"zeroize",
116
116
+
]
117
117
+
118
118
+
[[package]]
119
119
+
name = "aws-lc-sys"
120
120
+
version = "0.42.0"
121
121
+
source = "registry+https://github.com/rust-lang/crates.io-index"
122
122
+
checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444"
123
123
+
dependencies = [
124
124
+
"cc",
125
125
+
"cmake",
126
126
+
"dunce",
127
127
+
"fs_extra",
128
128
+
"pkg-config",
129
129
+
]
130
130
+
131
131
+
[[package]]
132
132
+
name = "base64"
133
133
+
version = "0.22.1"
134
134
+
source = "registry+https://github.com/rust-lang/crates.io-index"
135
135
+
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
136
136
+
137
137
+
[[package]]
138
138
+
name = "bitflags"
139
139
+
version = "1.3.2"
140
140
+
source = "registry+https://github.com/rust-lang/crates.io-index"
141
141
+
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
142
142
+
143
143
+
[[package]]
144
144
+
name = "bitflags"
145
145
+
version = "2.13.0"
146
146
+
source = "registry+https://github.com/rust-lang/crates.io-index"
147
147
+
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
148
148
+
149
149
+
[[package]]
150
150
+
name = "bumpalo"
151
151
+
version = "3.20.3"
152
152
+
source = "registry+https://github.com/rust-lang/crates.io-index"
153
153
+
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
154
154
+
155
155
+
[[package]]
156
156
+
name = "byteorder"
157
157
+
version = "1.5.0"
158
158
+
source = "registry+https://github.com/rust-lang/crates.io-index"
159
159
+
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
160
160
+
161
161
+
[[package]]
162
162
+
name = "bytes"
163
163
+
version = "1.12.0"
164
164
+
source = "registry+https://github.com/rust-lang/crates.io-index"
165
165
+
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
166
166
+
167
167
+
[[package]]
168
168
+
name = "cassowary"
169
169
+
version = "0.3.0"
170
170
+
source = "registry+https://github.com/rust-lang/crates.io-index"
171
171
+
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
172
172
+
173
173
+
[[package]]
174
174
+
name = "castaway"
175
175
+
version = "0.2.4"
176
176
+
source = "registry+https://github.com/rust-lang/crates.io-index"
177
177
+
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
178
178
+
dependencies = [
179
179
+
"rustversion",
180
180
+
]
181
181
+
182
182
+
[[package]]
183
183
+
name = "cc"
184
184
+
version = "1.2.65"
185
185
+
source = "registry+https://github.com/rust-lang/crates.io-index"
186
186
+
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
187
187
+
dependencies = [
188
188
+
"find-msvc-tools",
189
189
+
"jobserver",
190
190
+
"libc",
191
191
+
"shlex",
192
192
+
]
193
193
+
194
194
+
[[package]]
195
195
+
name = "cfg-if"
196
196
+
version = "1.0.4"
197
197
+
source = "registry+https://github.com/rust-lang/crates.io-index"
198
198
+
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
199
199
+
200
200
+
[[package]]
201
201
+
name = "cfg_aliases"
202
202
+
version = "0.2.1"
203
203
+
source = "registry+https://github.com/rust-lang/crates.io-index"
204
204
+
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
205
205
+
206
206
+
[[package]]
207
207
+
name = "chrono"
208
208
+
version = "0.4.45"
209
209
+
source = "registry+https://github.com/rust-lang/crates.io-index"
210
210
+
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
211
211
+
dependencies = [
212
212
+
"iana-time-zone",
213
213
+
"js-sys",
214
214
+
"num-traits",
215
215
+
"serde",
216
216
+
"wasm-bindgen",
217
217
+
"windows-link",
218
218
+
]
219
219
+
220
220
+
[[package]]
221
221
+
name = "clap"
222
222
+
version = "4.6.1"
223
223
+
source = "registry+https://github.com/rust-lang/crates.io-index"
224
224
+
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
225
225
+
dependencies = [
226
226
+
"clap_builder",
227
227
+
"clap_derive",
228
228
+
]
229
229
+
230
230
+
[[package]]
231
231
+
name = "clap_builder"
232
232
+
version = "4.6.0"
233
233
+
source = "registry+https://github.com/rust-lang/crates.io-index"
234
234
+
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
235
235
+
dependencies = [
236
236
+
"anstream",
237
237
+
"anstyle",
238
238
+
"clap_lex",
239
239
+
"strsim",
240
240
+
]
241
241
+
242
242
+
[[package]]
243
243
+
name = "clap_derive"
244
244
+
version = "4.6.1"
245
245
+
source = "registry+https://github.com/rust-lang/crates.io-index"
246
246
+
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
247
247
+
dependencies = [
248
248
+
"heck",
249
249
+
"proc-macro2",
250
250
+
"quote",
251
251
+
"syn",
252
252
+
]
253
253
+
254
254
+
[[package]]
255
255
+
name = "clap_lex"
256
256
+
version = "1.1.0"
257
257
+
source = "registry+https://github.com/rust-lang/crates.io-index"
258
258
+
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
259
259
+
260
260
+
[[package]]
261
261
+
name = "cmake"
262
262
+
version = "0.1.58"
263
263
+
source = "registry+https://github.com/rust-lang/crates.io-index"
264
264
+
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
265
265
+
dependencies = [
266
266
+
"cc",
267
267
+
]
268
268
+
269
269
+
[[package]]
270
270
+
name = "colorchoice"
271
271
+
version = "1.0.5"
272
272
+
source = "registry+https://github.com/rust-lang/crates.io-index"
273
273
+
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
274
274
+
275
275
+
[[package]]
276
276
+
name = "compact_str"
277
277
+
version = "0.8.2"
278
278
+
source = "registry+https://github.com/rust-lang/crates.io-index"
279
279
+
checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e"
280
280
+
dependencies = [
281
281
+
"castaway",
282
282
+
"cfg-if",
283
283
+
"itoa",
284
284
+
"rustversion",
285
285
+
"ryu",
286
286
+
"static_assertions",
287
287
+
]
288
288
+
289
289
+
[[package]]
290
290
+
name = "concurrent-queue"
291
291
+
version = "2.5.0"
292
292
+
source = "registry+https://github.com/rust-lang/crates.io-index"
293
293
+
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
294
294
+
dependencies = [
295
295
+
"crossbeam-utils",
296
296
+
]
297
297
+
298
298
+
[[package]]
299
299
+
name = "core-foundation"
300
300
+
version = "0.10.1"
301
301
+
source = "registry+https://github.com/rust-lang/crates.io-index"
302
302
+
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
303
303
+
dependencies = [
304
304
+
"core-foundation-sys",
305
305
+
"libc",
306
306
+
]
307
307
+
308
308
+
[[package]]
309
309
+
name = "core-foundation-sys"
310
310
+
version = "0.8.7"
311
311
+
source = "registry+https://github.com/rust-lang/crates.io-index"
312
312
+
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
313
313
+
314
314
+
[[package]]
315
315
+
name = "crossbeam-utils"
316
316
+
version = "0.8.21"
317
317
+
source = "registry+https://github.com/rust-lang/crates.io-index"
318
318
+
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
319
319
+
320
320
+
[[package]]
321
321
+
name = "crossterm"
322
322
+
version = "0.28.1"
323
323
+
source = "registry+https://github.com/rust-lang/crates.io-index"
324
324
+
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
325
325
+
dependencies = [
326
326
+
"bitflags 2.13.0",
327
327
+
"crossterm_winapi",
328
328
+
"futures-core",
329
329
+
"mio",
330
330
+
"parking_lot",
331
331
+
"rustix 0.38.44",
332
332
+
"signal-hook",
333
333
+
"signal-hook-mio",
334
334
+
"winapi",
335
335
+
]
336
336
+
337
337
+
[[package]]
338
338
+
name = "crossterm_winapi"
339
339
+
version = "0.9.1"
340
340
+
source = "registry+https://github.com/rust-lang/crates.io-index"
341
341
+
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
342
342
+
dependencies = [
343
343
+
"winapi",
344
344
+
]
345
345
+
346
346
+
[[package]]
347
347
+
name = "darling"
348
348
+
version = "0.23.0"
349
349
+
source = "registry+https://github.com/rust-lang/crates.io-index"
350
350
+
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
351
351
+
dependencies = [
352
352
+
"darling_core",
353
353
+
"darling_macro",
354
354
+
]
355
355
+
356
356
+
[[package]]
357
357
+
name = "darling_core"
358
358
+
version = "0.23.0"
359
359
+
source = "registry+https://github.com/rust-lang/crates.io-index"
360
360
+
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
361
361
+
dependencies = [
362
362
+
"ident_case",
363
363
+
"proc-macro2",
364
364
+
"quote",
365
365
+
"strsim",
366
366
+
"syn",
367
367
+
]
368
368
+
369
369
+
[[package]]
370
370
+
name = "darling_macro"
371
371
+
version = "0.23.0"
372
372
+
source = "registry+https://github.com/rust-lang/crates.io-index"
373
373
+
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
374
374
+
dependencies = [
375
375
+
"darling_core",
376
376
+
"quote",
377
377
+
"syn",
378
378
+
]
379
379
+
380
380
+
[[package]]
381
381
+
name = "directories"
382
382
+
version = "5.0.1"
383
383
+
source = "registry+https://github.com/rust-lang/crates.io-index"
384
384
+
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
385
385
+
dependencies = [
386
386
+
"dirs-sys",
387
387
+
]
388
388
+
389
389
+
[[package]]
390
390
+
name = "dirs-sys"
391
391
+
version = "0.4.1"
392
392
+
source = "registry+https://github.com/rust-lang/crates.io-index"
393
393
+
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
394
394
+
dependencies = [
395
395
+
"libc",
396
396
+
"option-ext",
397
397
+
"redox_users",
398
398
+
"windows-sys 0.48.0",
399
399
+
]
400
400
+
401
401
+
[[package]]
402
402
+
name = "displaydoc"
403
403
+
version = "0.2.6"
404
404
+
source = "registry+https://github.com/rust-lang/crates.io-index"
405
405
+
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
406
406
+
dependencies = [
407
407
+
"proc-macro2",
408
408
+
"quote",
409
409
+
"syn",
410
410
+
]
411
411
+
412
412
+
[[package]]
413
413
+
name = "dunce"
414
414
+
version = "1.0.5"
415
415
+
source = "registry+https://github.com/rust-lang/crates.io-index"
416
416
+
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
417
417
+
418
418
+
[[package]]
419
419
+
name = "either"
420
420
+
version = "1.16.0"
421
421
+
source = "registry+https://github.com/rust-lang/crates.io-index"
422
422
+
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
423
423
+
424
424
+
[[package]]
425
425
+
name = "equivalent"
426
426
+
version = "1.0.2"
427
427
+
source = "registry+https://github.com/rust-lang/crates.io-index"
428
428
+
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
429
429
+
430
430
+
[[package]]
431
431
+
name = "errno"
432
432
+
version = "0.3.14"
433
433
+
source = "registry+https://github.com/rust-lang/crates.io-index"
434
434
+
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
435
435
+
dependencies = [
436
436
+
"libc",
437
437
+
"windows-sys 0.61.2",
438
438
+
]
439
439
+
440
440
+
[[package]]
441
441
+
name = "fastrand"
442
442
+
version = "2.4.1"
443
443
+
source = "registry+https://github.com/rust-lang/crates.io-index"
444
444
+
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
445
445
+
446
446
+
[[package]]
447
447
+
name = "fin"
448
448
+
version = "0.1.0"
449
449
+
dependencies = [
450
450
+
"anyhow",
451
451
+
"clap",
452
452
+
"directories",
453
453
+
"fin-config",
454
454
+
"fin-jellyfin",
455
455
+
"fin-player",
456
456
+
"fin-tui",
457
457
+
"rpassword",
458
458
+
"serde",
459
459
+
"serde_json",
460
460
+
"tokio",
461
461
+
"toml",
462
462
+
"tracing",
463
463
+
"tracing-subscriber",
464
464
+
]
465
465
+
466
466
+
[[package]]
467
467
+
name = "fin-config"
468
468
+
version = "0.1.0"
469
469
+
dependencies = [
470
470
+
"anyhow",
471
471
+
"directories",
472
472
+
"serde",
473
473
+
"toml",
474
474
+
]
475
475
+
476
476
+
[[package]]
477
477
+
name = "fin-jellyfin"
478
478
+
version = "0.1.0"
479
479
+
dependencies = [
480
480
+
"anyhow",
481
481
+
"chrono",
482
482
+
"fin-config",
483
483
+
"percent-encoding",
484
484
+
"reqwest",
485
485
+
"serde",
486
486
+
"serde_json",
487
487
+
"thiserror 1.0.69",
488
488
+
"tokio",
489
489
+
"tracing",
490
490
+
"url",
491
491
+
"uuid",
492
492
+
"whoami",
493
493
+
]
494
494
+
495
495
+
[[package]]
496
496
+
name = "fin-player"
497
497
+
version = "0.1.0"
498
498
+
dependencies = [
499
499
+
"anyhow",
500
500
+
"async-trait",
501
501
+
"fin-jellyfin",
502
502
+
"futures",
503
503
+
"mdns-sd",
504
504
+
"parking_lot",
505
505
+
"rust_cast",
506
506
+
"serde",
507
507
+
"serde_json",
508
508
+
"thiserror 1.0.69",
509
509
+
"tokio",
510
510
+
"tracing",
511
511
+
"uuid",
512
512
+
]
513
513
+
514
514
+
[[package]]
515
515
+
name = "fin-tui"
516
516
+
version = "0.1.0"
517
517
+
dependencies = [
518
518
+
"anyhow",
519
519
+
"async-trait",
520
520
+
"chrono",
521
521
+
"crossterm",
522
522
+
"fin-config",
523
523
+
"fin-jellyfin",
524
524
+
"fin-player",
525
525
+
"futures",
526
526
+
"parking_lot",
527
527
+
"ratatui",
528
528
+
"serde",
529
529
+
"serde_json",
530
530
+
"thiserror 1.0.69",
531
531
+
"tokio",
532
532
+
"tracing",
533
533
+
"unicode-width 0.2.0",
534
534
+
]
535
535
+
536
536
+
[[package]]
537
537
+
name = "find-msvc-tools"
538
538
+
version = "0.1.9"
539
539
+
source = "registry+https://github.com/rust-lang/crates.io-index"
540
540
+
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
541
541
+
542
542
+
[[package]]
543
543
+
name = "flume"
544
544
+
version = "0.11.1"
545
545
+
source = "registry+https://github.com/rust-lang/crates.io-index"
546
546
+
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
547
547
+
dependencies = [
548
548
+
"futures-core",
549
549
+
"futures-sink",
550
550
+
"spin",
551
551
+
]
552
552
+
553
553
+
[[package]]
554
554
+
name = "foldhash"
555
555
+
version = "0.1.5"
556
556
+
source = "registry+https://github.com/rust-lang/crates.io-index"
557
557
+
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
558
558
+
559
559
+
[[package]]
560
560
+
name = "form_urlencoded"
561
561
+
version = "1.2.2"
562
562
+
source = "registry+https://github.com/rust-lang/crates.io-index"
563
563
+
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
564
564
+
dependencies = [
565
565
+
"percent-encoding",
566
566
+
]
567
567
+
568
568
+
[[package]]
569
569
+
name = "fs_extra"
570
570
+
version = "1.3.0"
571
571
+
source = "registry+https://github.com/rust-lang/crates.io-index"
572
572
+
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
573
573
+
574
574
+
[[package]]
575
575
+
name = "futures"
576
576
+
version = "0.3.32"
577
577
+
source = "registry+https://github.com/rust-lang/crates.io-index"
578
578
+
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
579
579
+
dependencies = [
580
580
+
"futures-channel",
581
581
+
"futures-core",
582
582
+
"futures-executor",
583
583
+
"futures-io",
584
584
+
"futures-sink",
585
585
+
"futures-task",
586
586
+
"futures-util",
587
587
+
]
588
588
+
589
589
+
[[package]]
590
590
+
name = "futures-channel"
591
591
+
version = "0.3.32"
592
592
+
source = "registry+https://github.com/rust-lang/crates.io-index"
593
593
+
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
594
594
+
dependencies = [
595
595
+
"futures-core",
596
596
+
"futures-sink",
597
597
+
]
598
598
+
599
599
+
[[package]]
600
600
+
name = "futures-core"
601
601
+
version = "0.3.32"
602
602
+
source = "registry+https://github.com/rust-lang/crates.io-index"
603
603
+
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
604
604
+
605
605
+
[[package]]
606
606
+
name = "futures-executor"
607
607
+
version = "0.3.32"
608
608
+
source = "registry+https://github.com/rust-lang/crates.io-index"
609
609
+
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
610
610
+
dependencies = [
611
611
+
"futures-core",
612
612
+
"futures-task",
613
613
+
"futures-util",
614
614
+
]
615
615
+
616
616
+
[[package]]
617
617
+
name = "futures-io"
618
618
+
version = "0.3.32"
619
619
+
source = "registry+https://github.com/rust-lang/crates.io-index"
620
620
+
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
621
621
+
622
622
+
[[package]]
623
623
+
name = "futures-macro"
624
624
+
version = "0.3.32"
625
625
+
source = "registry+https://github.com/rust-lang/crates.io-index"
626
626
+
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
627
627
+
dependencies = [
628
628
+
"proc-macro2",
629
629
+
"quote",
630
630
+
"syn",
631
631
+
]
632
632
+
633
633
+
[[package]]
634
634
+
name = "futures-sink"
635
635
+
version = "0.3.32"
636
636
+
source = "registry+https://github.com/rust-lang/crates.io-index"
637
637
+
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
638
638
+
639
639
+
[[package]]
640
640
+
name = "futures-task"
641
641
+
version = "0.3.32"
642
642
+
source = "registry+https://github.com/rust-lang/crates.io-index"
643
643
+
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
644
644
+
645
645
+
[[package]]
646
646
+
name = "futures-util"
647
647
+
version = "0.3.32"
648
648
+
source = "registry+https://github.com/rust-lang/crates.io-index"
649
649
+
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
650
650
+
dependencies = [
651
651
+
"futures-channel",
652
652
+
"futures-core",
653
653
+
"futures-io",
654
654
+
"futures-macro",
655
655
+
"futures-sink",
656
656
+
"futures-task",
657
657
+
"memchr",
658
658
+
"pin-project-lite",
659
659
+
"slab",
660
660
+
]
661
661
+
662
662
+
[[package]]
663
663
+
name = "getrandom"
664
664
+
version = "0.2.17"
665
665
+
source = "registry+https://github.com/rust-lang/crates.io-index"
666
666
+
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
667
667
+
dependencies = [
668
668
+
"cfg-if",
669
669
+
"js-sys",
670
670
+
"libc",
671
671
+
"wasi",
672
672
+
"wasm-bindgen",
673
673
+
]
674
674
+
675
675
+
[[package]]
676
676
+
name = "getrandom"
677
677
+
version = "0.3.4"
678
678
+
source = "registry+https://github.com/rust-lang/crates.io-index"
679
679
+
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
680
680
+
dependencies = [
681
681
+
"cfg-if",
682
682
+
"js-sys",
683
683
+
"libc",
684
684
+
"r-efi 5.3.0",
685
685
+
"wasip2",
686
686
+
"wasm-bindgen",
687
687
+
]
688
688
+
689
689
+
[[package]]
690
690
+
name = "getrandom"
691
691
+
version = "0.4.3"
692
692
+
source = "registry+https://github.com/rust-lang/crates.io-index"
693
693
+
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
694
694
+
dependencies = [
695
695
+
"cfg-if",
696
696
+
"libc",
697
697
+
"r-efi 6.0.0",
698
698
+
]
699
699
+
700
700
+
[[package]]
701
701
+
name = "hashbrown"
702
702
+
version = "0.15.5"
703
703
+
source = "registry+https://github.com/rust-lang/crates.io-index"
704
704
+
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
705
705
+
dependencies = [
706
706
+
"allocator-api2",
707
707
+
"equivalent",
708
708
+
"foldhash",
709
709
+
]
710
710
+
711
711
+
[[package]]
712
712
+
name = "hashbrown"
713
713
+
version = "0.17.1"
714
714
+
source = "registry+https://github.com/rust-lang/crates.io-index"
715
715
+
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
716
716
+
717
717
+
[[package]]
718
718
+
name = "heck"
719
719
+
version = "0.5.0"
720
720
+
source = "registry+https://github.com/rust-lang/crates.io-index"
721
721
+
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
722
722
+
723
723
+
[[package]]
724
724
+
name = "home"
725
725
+
version = "0.5.12"
726
726
+
source = "registry+https://github.com/rust-lang/crates.io-index"
727
727
+
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
728
728
+
dependencies = [
729
729
+
"windows-sys 0.61.2",
730
730
+
]
731
731
+
732
732
+
[[package]]
733
733
+
name = "http"
734
734
+
version = "1.4.2"
735
735
+
source = "registry+https://github.com/rust-lang/crates.io-index"
736
736
+
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
737
737
+
dependencies = [
738
738
+
"bytes",
739
739
+
"itoa",
740
740
+
]
741
741
+
742
742
+
[[package]]
743
743
+
name = "http-body"
744
744
+
version = "1.0.1"
745
745
+
source = "registry+https://github.com/rust-lang/crates.io-index"
746
746
+
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
747
747
+
dependencies = [
748
748
+
"bytes",
749
749
+
"http",
750
750
+
]
751
751
+
752
752
+
[[package]]
753
753
+
name = "http-body-util"
754
754
+
version = "0.1.3"
755
755
+
source = "registry+https://github.com/rust-lang/crates.io-index"
756
756
+
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
757
757
+
dependencies = [
758
758
+
"bytes",
759
759
+
"futures-core",
760
760
+
"http",
761
761
+
"http-body",
762
762
+
"pin-project-lite",
763
763
+
]
764
764
+
765
765
+
[[package]]
766
766
+
name = "httparse"
767
767
+
version = "1.10.1"
768
768
+
source = "registry+https://github.com/rust-lang/crates.io-index"
769
769
+
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
770
770
+
771
771
+
[[package]]
772
772
+
name = "hyper"
773
773
+
version = "1.10.1"
774
774
+
source = "registry+https://github.com/rust-lang/crates.io-index"
775
775
+
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
776
776
+
dependencies = [
777
777
+
"atomic-waker",
778
778
+
"bytes",
779
779
+
"futures-channel",
780
780
+
"futures-core",
781
781
+
"http",
782
782
+
"http-body",
783
783
+
"httparse",
784
784
+
"itoa",
785
785
+
"pin-project-lite",
786
786
+
"smallvec",
787
787
+
"tokio",
788
788
+
"want",
789
789
+
]
790
790
+
791
791
+
[[package]]
792
792
+
name = "hyper-rustls"
793
793
+
version = "0.27.9"
794
794
+
source = "registry+https://github.com/rust-lang/crates.io-index"
795
795
+
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
796
796
+
dependencies = [
797
797
+
"http",
798
798
+
"hyper",
799
799
+
"hyper-util",
800
800
+
"rustls",
801
801
+
"tokio",
802
802
+
"tokio-rustls",
803
803
+
"tower-service",
804
804
+
"webpki-roots",
805
805
+
]
806
806
+
807
807
+
[[package]]
808
808
+
name = "hyper-util"
809
809
+
version = "0.1.20"
810
810
+
source = "registry+https://github.com/rust-lang/crates.io-index"
811
811
+
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
812
812
+
dependencies = [
813
813
+
"base64",
814
814
+
"bytes",
815
815
+
"futures-channel",
816
816
+
"futures-util",
817
817
+
"http",
818
818
+
"http-body",
819
819
+
"hyper",
820
820
+
"ipnet",
821
821
+
"libc",
822
822
+
"percent-encoding",
823
823
+
"pin-project-lite",
824
824
+
"socket2 0.6.4",
825
825
+
"tokio",
826
826
+
"tower-service",
827
827
+
"tracing",
828
828
+
]
829
829
+
830
830
+
[[package]]
831
831
+
name = "iana-time-zone"
832
832
+
version = "0.1.65"
833
833
+
source = "registry+https://github.com/rust-lang/crates.io-index"
834
834
+
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
835
835
+
dependencies = [
836
836
+
"android_system_properties",
837
837
+
"core-foundation-sys",
838
838
+
"iana-time-zone-haiku",
839
839
+
"js-sys",
840
840
+
"log",
841
841
+
"wasm-bindgen",
842
842
+
"windows-core",
843
843
+
]
844
844
+
845
845
+
[[package]]
846
846
+
name = "iana-time-zone-haiku"
847
847
+
version = "0.1.2"
848
848
+
source = "registry+https://github.com/rust-lang/crates.io-index"
849
849
+
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
850
850
+
dependencies = [
851
851
+
"cc",
852
852
+
]
853
853
+
854
854
+
[[package]]
855
855
+
name = "icu_collections"
856
856
+
version = "2.2.0"
857
857
+
source = "registry+https://github.com/rust-lang/crates.io-index"
858
858
+
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
859
859
+
dependencies = [
860
860
+
"displaydoc",
861
861
+
"potential_utf",
862
862
+
"utf8_iter",
863
863
+
"yoke",
864
864
+
"zerofrom",
865
865
+
"zerovec",
866
866
+
]
867
867
+
868
868
+
[[package]]
869
869
+
name = "icu_locale_core"
870
870
+
version = "2.2.0"
871
871
+
source = "registry+https://github.com/rust-lang/crates.io-index"
872
872
+
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
873
873
+
dependencies = [
874
874
+
"displaydoc",
875
875
+
"litemap",
876
876
+
"tinystr",
877
877
+
"writeable",
878
878
+
"zerovec",
879
879
+
]
880
880
+
881
881
+
[[package]]
882
882
+
name = "icu_normalizer"
883
883
+
version = "2.2.0"
884
884
+
source = "registry+https://github.com/rust-lang/crates.io-index"
885
885
+
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
886
886
+
dependencies = [
887
887
+
"icu_collections",
888
888
+
"icu_normalizer_data",
889
889
+
"icu_properties",
890
890
+
"icu_provider",
891
891
+
"smallvec",
892
892
+
"zerovec",
893
893
+
]
894
894
+
895
895
+
[[package]]
896
896
+
name = "icu_normalizer_data"
897
897
+
version = "2.2.0"
898
898
+
source = "registry+https://github.com/rust-lang/crates.io-index"
899
899
+
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
900
900
+
901
901
+
[[package]]
902
902
+
name = "icu_properties"
903
903
+
version = "2.2.0"
904
904
+
source = "registry+https://github.com/rust-lang/crates.io-index"
905
905
+
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
906
906
+
dependencies = [
907
907
+
"icu_collections",
908
908
+
"icu_locale_core",
909
909
+
"icu_properties_data",
910
910
+
"icu_provider",
911
911
+
"zerotrie",
912
912
+
"zerovec",
913
913
+
]
914
914
+
915
915
+
[[package]]
916
916
+
name = "icu_properties_data"
917
917
+
version = "2.2.0"
918
918
+
source = "registry+https://github.com/rust-lang/crates.io-index"
919
919
+
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
920
920
+
921
921
+
[[package]]
922
922
+
name = "icu_provider"
923
923
+
version = "2.2.0"
924
924
+
source = "registry+https://github.com/rust-lang/crates.io-index"
925
925
+
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
926
926
+
dependencies = [
927
927
+
"displaydoc",
928
928
+
"icu_locale_core",
929
929
+
"writeable",
930
930
+
"yoke",
931
931
+
"zerofrom",
932
932
+
"zerotrie",
933
933
+
"zerovec",
934
934
+
]
935
935
+
936
936
+
[[package]]
937
937
+
name = "ident_case"
938
938
+
version = "1.0.1"
939
939
+
source = "registry+https://github.com/rust-lang/crates.io-index"
940
940
+
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
941
941
+
942
942
+
[[package]]
943
943
+
name = "idna"
944
944
+
version = "1.1.0"
945
945
+
source = "registry+https://github.com/rust-lang/crates.io-index"
946
946
+
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
947
947
+
dependencies = [
948
948
+
"idna_adapter",
949
949
+
"smallvec",
950
950
+
"utf8_iter",
951
951
+
]
952
952
+
953
953
+
[[package]]
954
954
+
name = "idna_adapter"
955
955
+
version = "1.2.2"
956
956
+
source = "registry+https://github.com/rust-lang/crates.io-index"
957
957
+
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
958
958
+
dependencies = [
959
959
+
"icu_normalizer",
960
960
+
"icu_properties",
961
961
+
]
962
962
+
963
963
+
[[package]]
964
964
+
name = "if-addrs"
965
965
+
version = "0.13.4"
966
966
+
source = "registry+https://github.com/rust-lang/crates.io-index"
967
967
+
checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24"
968
968
+
dependencies = [
969
969
+
"libc",
970
970
+
"windows-sys 0.59.0",
971
971
+
]
972
972
+
973
973
+
[[package]]
974
974
+
name = "indexmap"
975
975
+
version = "2.14.0"
976
976
+
source = "registry+https://github.com/rust-lang/crates.io-index"
977
977
+
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
978
978
+
dependencies = [
979
979
+
"equivalent",
980
980
+
"hashbrown 0.17.1",
981
981
+
]
982
982
+
983
983
+
[[package]]
984
984
+
name = "indoc"
985
985
+
version = "2.0.7"
986
986
+
source = "registry+https://github.com/rust-lang/crates.io-index"
987
987
+
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
988
988
+
dependencies = [
989
989
+
"rustversion",
990
990
+
]
991
991
+
992
992
+
[[package]]
993
993
+
name = "instability"
994
994
+
version = "0.3.12"
995
995
+
source = "registry+https://github.com/rust-lang/crates.io-index"
996
996
+
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
997
997
+
dependencies = [
998
998
+
"darling",
999
999
+
"indoc",
1000
1000
+
"proc-macro2",
1001
1001
+
"quote",
1002
1002
+
"syn",
1003
1003
+
]
1004
1004
+
1005
1005
+
[[package]]
1006
1006
+
name = "ipnet"
1007
1007
+
version = "2.12.0"
1008
1008
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1009
1009
+
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
1010
1010
+
1011
1011
+
[[package]]
1012
1012
+
name = "is_terminal_polyfill"
1013
1013
+
version = "1.70.2"
1014
1014
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1015
1015
+
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
1016
1016
+
1017
1017
+
[[package]]
1018
1018
+
name = "itertools"
1019
1019
+
version = "0.13.0"
1020
1020
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1021
1021
+
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
1022
1022
+
dependencies = [
1023
1023
+
"either",
1024
1024
+
]
1025
1025
+
1026
1026
+
[[package]]
1027
1027
+
name = "itoa"
1028
1028
+
version = "1.0.18"
1029
1029
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1030
1030
+
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
1031
1031
+
1032
1032
+
[[package]]
1033
1033
+
name = "jobserver"
1034
1034
+
version = "0.1.34"
1035
1035
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1036
1036
+
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
1037
1037
+
dependencies = [
1038
1038
+
"getrandom 0.3.4",
1039
1039
+
"libc",
1040
1040
+
]
1041
1041
+
1042
1042
+
[[package]]
1043
1043
+
name = "js-sys"
1044
1044
+
version = "0.3.103"
1045
1045
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1046
1046
+
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
1047
1047
+
dependencies = [
1048
1048
+
"cfg-if",
1049
1049
+
"futures-util",
1050
1050
+
"wasm-bindgen",
1051
1051
+
]
1052
1052
+
1053
1053
+
[[package]]
1054
1054
+
name = "lazy_static"
1055
1055
+
version = "1.5.0"
1056
1056
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1057
1057
+
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
1058
1058
+
1059
1059
+
[[package]]
1060
1060
+
name = "libc"
1061
1061
+
version = "0.2.186"
1062
1062
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1063
1063
+
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
1064
1064
+
1065
1065
+
[[package]]
1066
1066
+
name = "libredox"
1067
1067
+
version = "0.1.18"
1068
1068
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1069
1069
+
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
1070
1070
+
dependencies = [
1071
1071
+
"bitflags 2.13.0",
1072
1072
+
"libc",
1073
1073
+
"plain",
1074
1074
+
"redox_syscall 0.9.0",
1075
1075
+
]
1076
1076
+
1077
1077
+
[[package]]
1078
1078
+
name = "linux-raw-sys"
1079
1079
+
version = "0.4.15"
1080
1080
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1081
1081
+
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
1082
1082
+
1083
1083
+
[[package]]
1084
1084
+
name = "linux-raw-sys"
1085
1085
+
version = "0.12.1"
1086
1086
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1087
1087
+
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
1088
1088
+
1089
1089
+
[[package]]
1090
1090
+
name = "litemap"
1091
1091
+
version = "0.8.2"
1092
1092
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1093
1093
+
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
1094
1094
+
1095
1095
+
[[package]]
1096
1096
+
name = "lock_api"
1097
1097
+
version = "0.4.14"
1098
1098
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1099
1099
+
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
1100
1100
+
dependencies = [
1101
1101
+
"scopeguard",
1102
1102
+
]
1103
1103
+
1104
1104
+
[[package]]
1105
1105
+
name = "log"
1106
1106
+
version = "0.4.33"
1107
1107
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1108
1108
+
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
1109
1109
+
1110
1110
+
[[package]]
1111
1111
+
name = "lru"
1112
1112
+
version = "0.12.5"
1113
1113
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1114
1114
+
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
1115
1115
+
dependencies = [
1116
1116
+
"hashbrown 0.15.5",
1117
1117
+
]
1118
1118
+
1119
1119
+
[[package]]
1120
1120
+
name = "lru-slab"
1121
1121
+
version = "0.1.2"
1122
1122
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1123
1123
+
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
1124
1124
+
1125
1125
+
[[package]]
1126
1126
+
name = "matchers"
1127
1127
+
version = "0.2.0"
1128
1128
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1129
1129
+
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
1130
1130
+
dependencies = [
1131
1131
+
"regex-automata",
1132
1132
+
]
1133
1133
+
1134
1134
+
[[package]]
1135
1135
+
name = "mdns-sd"
1136
1136
+
version = "0.11.5"
1137
1137
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1138
1138
+
checksum = "8fe7c11a1eb3cfbfcf702d1601c1f5f4c102cdc8665b8a557783ef634741676e"
1139
1139
+
dependencies = [
1140
1140
+
"flume",
1141
1141
+
"if-addrs",
1142
1142
+
"log",
1143
1143
+
"polling",
1144
1144
+
"socket2 0.5.10",
1145
1145
+
]
1146
1146
+
1147
1147
+
[[package]]
1148
1148
+
name = "memchr"
1149
1149
+
version = "2.8.2"
1150
1150
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1151
1151
+
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
1152
1152
+
1153
1153
+
[[package]]
1154
1154
+
name = "mio"
1155
1155
+
version = "1.2.1"
1156
1156
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1157
1157
+
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
1158
1158
+
dependencies = [
1159
1159
+
"libc",
1160
1160
+
"log",
1161
1161
+
"wasi",
1162
1162
+
"windows-sys 0.61.2",
1163
1163
+
]
1164
1164
+
1165
1165
+
[[package]]
1166
1166
+
name = "nu-ansi-term"
1167
1167
+
version = "0.50.3"
1168
1168
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1169
1169
+
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
1170
1170
+
dependencies = [
1171
1171
+
"windows-sys 0.61.2",
1172
1172
+
]
1173
1173
+
1174
1174
+
[[package]]
1175
1175
+
name = "num-traits"
1176
1176
+
version = "0.2.19"
1177
1177
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1178
1178
+
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
1179
1179
+
dependencies = [
1180
1180
+
"autocfg",
1181
1181
+
]
1182
1182
+
1183
1183
+
[[package]]
1184
1184
+
name = "once_cell"
1185
1185
+
version = "1.21.4"
1186
1186
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1187
1187
+
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
1188
1188
+
1189
1189
+
[[package]]
1190
1190
+
name = "once_cell_polyfill"
1191
1191
+
version = "1.70.2"
1192
1192
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1193
1193
+
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
1194
1194
+
1195
1195
+
[[package]]
1196
1196
+
name = "openssl-probe"
1197
1197
+
version = "0.2.1"
1198
1198
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1199
1199
+
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
1200
1200
+
1201
1201
+
[[package]]
1202
1202
+
name = "option-ext"
1203
1203
+
version = "0.2.0"
1204
1204
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1205
1205
+
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
1206
1206
+
1207
1207
+
[[package]]
1208
1208
+
name = "parking_lot"
1209
1209
+
version = "0.12.5"
1210
1210
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1211
1211
+
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
1212
1212
+
dependencies = [
1213
1213
+
"lock_api",
1214
1214
+
"parking_lot_core",
1215
1215
+
]
1216
1216
+
1217
1217
+
[[package]]
1218
1218
+
name = "parking_lot_core"
1219
1219
+
version = "0.9.12"
1220
1220
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1221
1221
+
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
1222
1222
+
dependencies = [
1223
1223
+
"cfg-if",
1224
1224
+
"libc",
1225
1225
+
"redox_syscall 0.5.18",
1226
1226
+
"smallvec",
1227
1227
+
"windows-link",
1228
1228
+
]
1229
1229
+
1230
1230
+
[[package]]
1231
1231
+
name = "paste"
1232
1232
+
version = "1.0.15"
1233
1233
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1234
1234
+
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
1235
1235
+
1236
1236
+
[[package]]
1237
1237
+
name = "percent-encoding"
1238
1238
+
version = "2.3.2"
1239
1239
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1240
1240
+
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1241
1241
+
1242
1242
+
[[package]]
1243
1243
+
name = "pin-project-lite"
1244
1244
+
version = "0.2.17"
1245
1245
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1246
1246
+
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1247
1247
+
1248
1248
+
[[package]]
1249
1249
+
name = "pkg-config"
1250
1250
+
version = "0.3.33"
1251
1251
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1252
1252
+
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
1253
1253
+
1254
1254
+
[[package]]
1255
1255
+
name = "plain"
1256
1256
+
version = "0.2.3"
1257
1257
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1258
1258
+
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
1259
1259
+
1260
1260
+
[[package]]
1261
1261
+
name = "polling"
1262
1262
+
version = "2.8.0"
1263
1263
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1264
1264
+
checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce"
1265
1265
+
dependencies = [
1266
1266
+
"autocfg",
1267
1267
+
"bitflags 1.3.2",
1268
1268
+
"cfg-if",
1269
1269
+
"concurrent-queue",
1270
1270
+
"libc",
1271
1271
+
"log",
1272
1272
+
"pin-project-lite",
1273
1273
+
"windows-sys 0.48.0",
1274
1274
+
]
1275
1275
+
1276
1276
+
[[package]]
1277
1277
+
name = "potential_utf"
1278
1278
+
version = "0.1.5"
1279
1279
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1280
1280
+
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
1281
1281
+
dependencies = [
1282
1282
+
"zerovec",
1283
1283
+
]
1284
1284
+
1285
1285
+
[[package]]
1286
1286
+
name = "ppv-lite86"
1287
1287
+
version = "0.2.21"
1288
1288
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1289
1289
+
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
1290
1290
+
dependencies = [
1291
1291
+
"zerocopy",
1292
1292
+
]
1293
1293
+
1294
1294
+
[[package]]
1295
1295
+
name = "proc-macro2"
1296
1296
+
version = "1.0.106"
1297
1297
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1298
1298
+
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
1299
1299
+
dependencies = [
1300
1300
+
"unicode-ident",
1301
1301
+
]
1302
1302
+
1303
1303
+
[[package]]
1304
1304
+
name = "protobuf"
1305
1305
+
version = "3.7.2"
1306
1306
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1307
1307
+
checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4"
1308
1308
+
dependencies = [
1309
1309
+
"once_cell",
1310
1310
+
"protobuf-support",
1311
1311
+
"thiserror 1.0.69",
1312
1312
+
]
1313
1313
+
1314
1314
+
[[package]]
1315
1315
+
name = "protobuf-codegen"
1316
1316
+
version = "3.7.2"
1317
1317
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1318
1318
+
checksum = "5d3976825c0014bbd2f3b34f0001876604fe87e0c86cd8fa54251530f1544ace"
1319
1319
+
dependencies = [
1320
1320
+
"anyhow",
1321
1321
+
"once_cell",
1322
1322
+
"protobuf",
1323
1323
+
"protobuf-parse",
1324
1324
+
"regex",
1325
1325
+
"tempfile",
1326
1326
+
"thiserror 1.0.69",
1327
1327
+
]
1328
1328
+
1329
1329
+
[[package]]
1330
1330
+
name = "protobuf-parse"
1331
1331
+
version = "3.7.2"
1332
1332
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1333
1333
+
checksum = "b4aeaa1f2460f1d348eeaeed86aea999ce98c1bded6f089ff8514c9d9dbdc973"
1334
1334
+
dependencies = [
1335
1335
+
"anyhow",
1336
1336
+
"indexmap",
1337
1337
+
"log",
1338
1338
+
"protobuf",
1339
1339
+
"protobuf-support",
1340
1340
+
"tempfile",
1341
1341
+
"thiserror 1.0.69",
1342
1342
+
"which",
1343
1343
+
]
1344
1344
+
1345
1345
+
[[package]]
1346
1346
+
name = "protobuf-support"
1347
1347
+
version = "3.7.2"
1348
1348
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1349
1349
+
checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6"
1350
1350
+
dependencies = [
1351
1351
+
"thiserror 1.0.69",
1352
1352
+
]
1353
1353
+
1354
1354
+
[[package]]
1355
1355
+
name = "quinn"
1356
1356
+
version = "0.11.11"
1357
1357
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1358
1358
+
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
1359
1359
+
dependencies = [
1360
1360
+
"bytes",
1361
1361
+
"cfg_aliases",
1362
1362
+
"pin-project-lite",
1363
1363
+
"quinn-proto",
1364
1364
+
"quinn-udp",
1365
1365
+
"rustc-hash",
1366
1366
+
"rustls",
1367
1367
+
"socket2 0.6.4",
1368
1368
+
"thiserror 2.0.18",
1369
1369
+
"tokio",
1370
1370
+
"tracing",
1371
1371
+
"web-time",
1372
1372
+
]
1373
1373
+
1374
1374
+
[[package]]
1375
1375
+
name = "quinn-proto"
1376
1376
+
version = "0.11.15"
1377
1377
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1378
1378
+
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
1379
1379
+
dependencies = [
1380
1380
+
"bytes",
1381
1381
+
"getrandom 0.3.4",
1382
1382
+
"lru-slab",
1383
1383
+
"rand",
1384
1384
+
"ring",
1385
1385
+
"rustc-hash",
1386
1386
+
"rustls",
1387
1387
+
"rustls-pki-types",
1388
1388
+
"slab",
1389
1389
+
"thiserror 2.0.18",
1390
1390
+
"tinyvec",
1391
1391
+
"tracing",
1392
1392
+
"web-time",
1393
1393
+
]
1394
1394
+
1395
1395
+
[[package]]
1396
1396
+
name = "quinn-udp"
1397
1397
+
version = "0.5.14"
1398
1398
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1399
1399
+
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
1400
1400
+
dependencies = [
1401
1401
+
"cfg_aliases",
1402
1402
+
"libc",
1403
1403
+
"once_cell",
1404
1404
+
"socket2 0.6.4",
1405
1405
+
"tracing",
1406
1406
+
"windows-sys 0.60.2",
1407
1407
+
]
1408
1408
+
1409
1409
+
[[package]]
1410
1410
+
name = "quote"
1411
1411
+
version = "1.0.46"
1412
1412
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1413
1413
+
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
1414
1414
+
dependencies = [
1415
1415
+
"proc-macro2",
1416
1416
+
]
1417
1417
+
1418
1418
+
[[package]]
1419
1419
+
name = "r-efi"
1420
1420
+
version = "5.3.0"
1421
1421
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1422
1422
+
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
1423
1423
+
1424
1424
+
[[package]]
1425
1425
+
name = "r-efi"
1426
1426
+
version = "6.0.0"
1427
1427
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1428
1428
+
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1429
1429
+
1430
1430
+
[[package]]
1431
1431
+
name = "rand"
1432
1432
+
version = "0.9.4"
1433
1433
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1434
1434
+
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
1435
1435
+
dependencies = [
1436
1436
+
"rand_chacha",
1437
1437
+
"rand_core",
1438
1438
+
]
1439
1439
+
1440
1440
+
[[package]]
1441
1441
+
name = "rand_chacha"
1442
1442
+
version = "0.9.0"
1443
1443
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1444
1444
+
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1445
1445
+
dependencies = [
1446
1446
+
"ppv-lite86",
1447
1447
+
"rand_core",
1448
1448
+
]
1449
1449
+
1450
1450
+
[[package]]
1451
1451
+
name = "rand_core"
1452
1452
+
version = "0.9.5"
1453
1453
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1454
1454
+
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
1455
1455
+
dependencies = [
1456
1456
+
"getrandom 0.3.4",
1457
1457
+
]
1458
1458
+
1459
1459
+
[[package]]
1460
1460
+
name = "ratatui"
1461
1461
+
version = "0.29.0"
1462
1462
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1463
1463
+
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
1464
1464
+
dependencies = [
1465
1465
+
"bitflags 2.13.0",
1466
1466
+
"cassowary",
1467
1467
+
"compact_str",
1468
1468
+
"crossterm",
1469
1469
+
"indoc",
1470
1470
+
"instability",
1471
1471
+
"itertools",
1472
1472
+
"lru",
1473
1473
+
"paste",
1474
1474
+
"strum",
1475
1475
+
"unicode-segmentation",
1476
1476
+
"unicode-truncate",
1477
1477
+
"unicode-width 0.2.0",
1478
1478
+
]
1479
1479
+
1480
1480
+
[[package]]
1481
1481
+
name = "redox_syscall"
1482
1482
+
version = "0.5.18"
1483
1483
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1484
1484
+
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
1485
1485
+
dependencies = [
1486
1486
+
"bitflags 2.13.0",
1487
1487
+
]
1488
1488
+
1489
1489
+
[[package]]
1490
1490
+
name = "redox_syscall"
1491
1491
+
version = "0.9.0"
1492
1492
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1493
1493
+
checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759"
1494
1494
+
dependencies = [
1495
1495
+
"bitflags 2.13.0",
1496
1496
+
]
1497
1497
+
1498
1498
+
[[package]]
1499
1499
+
name = "redox_users"
1500
1500
+
version = "0.4.6"
1501
1501
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1502
1502
+
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
1503
1503
+
dependencies = [
1504
1504
+
"getrandom 0.2.17",
1505
1505
+
"libredox",
1506
1506
+
"thiserror 1.0.69",
1507
1507
+
]
1508
1508
+
1509
1509
+
[[package]]
1510
1510
+
name = "regex"
1511
1511
+
version = "1.12.4"
1512
1512
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1513
1513
+
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
1514
1514
+
dependencies = [
1515
1515
+
"aho-corasick",
1516
1516
+
"memchr",
1517
1517
+
"regex-automata",
1518
1518
+
"regex-syntax",
1519
1519
+
]
1520
1520
+
1521
1521
+
[[package]]
1522
1522
+
name = "regex-automata"
1523
1523
+
version = "0.4.14"
1524
1524
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1525
1525
+
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
1526
1526
+
dependencies = [
1527
1527
+
"aho-corasick",
1528
1528
+
"memchr",
1529
1529
+
"regex-syntax",
1530
1530
+
]
1531
1531
+
1532
1532
+
[[package]]
1533
1533
+
name = "regex-syntax"
1534
1534
+
version = "0.8.11"
1535
1535
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1536
1536
+
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
1537
1537
+
1538
1538
+
[[package]]
1539
1539
+
name = "reqwest"
1540
1540
+
version = "0.12.28"
1541
1541
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1542
1542
+
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
1543
1543
+
dependencies = [
1544
1544
+
"base64",
1545
1545
+
"bytes",
1546
1546
+
"futures-core",
1547
1547
+
"http",
1548
1548
+
"http-body",
1549
1549
+
"http-body-util",
1550
1550
+
"hyper",
1551
1551
+
"hyper-rustls",
1552
1552
+
"hyper-util",
1553
1553
+
"js-sys",
1554
1554
+
"log",
1555
1555
+
"percent-encoding",
1556
1556
+
"pin-project-lite",
1557
1557
+
"quinn",
1558
1558
+
"rustls",
1559
1559
+
"rustls-pki-types",
1560
1560
+
"serde",
1561
1561
+
"serde_json",
1562
1562
+
"serde_urlencoded",
1563
1563
+
"sync_wrapper",
1564
1564
+
"tokio",
1565
1565
+
"tokio-rustls",
1566
1566
+
"tower",
1567
1567
+
"tower-http",
1568
1568
+
"tower-service",
1569
1569
+
"url",
1570
1570
+
"wasm-bindgen",
1571
1571
+
"wasm-bindgen-futures",
1572
1572
+
"web-sys",
1573
1573
+
"webpki-roots",
1574
1574
+
]
1575
1575
+
1576
1576
+
[[package]]
1577
1577
+
name = "ring"
1578
1578
+
version = "0.17.14"
1579
1579
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1580
1580
+
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
1581
1581
+
dependencies = [
1582
1582
+
"cc",
1583
1583
+
"cfg-if",
1584
1584
+
"getrandom 0.2.17",
1585
1585
+
"libc",
1586
1586
+
"untrusted",
1587
1587
+
"windows-sys 0.52.0",
1588
1588
+
]
1589
1589
+
1590
1590
+
[[package]]
1591
1591
+
name = "rpassword"
1592
1592
+
version = "7.5.4"
1593
1593
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1594
1594
+
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
1595
1595
+
dependencies = [
1596
1596
+
"libc",
1597
1597
+
"rtoolbox",
1598
1598
+
"windows-sys 0.61.2",
1599
1599
+
]
1600
1600
+
1601
1601
+
[[package]]
1602
1602
+
name = "rtoolbox"
1603
1603
+
version = "0.0.5"
1604
1604
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1605
1605
+
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
1606
1606
+
dependencies = [
1607
1607
+
"libc",
1608
1608
+
"windows-sys 0.59.0",
1609
1609
+
]
1610
1610
+
1611
1611
+
[[package]]
1612
1612
+
name = "rust_cast"
1613
1613
+
version = "0.20.0"
1614
1614
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1615
1615
+
checksum = "c403f9630e398a4a5f2655df392254fcade5e4df94b11308487f2aea49ddee46"
1616
1616
+
dependencies = [
1617
1617
+
"byteorder",
1618
1618
+
"log",
1619
1619
+
"protobuf",
1620
1620
+
"protobuf-codegen",
1621
1621
+
"rustls",
1622
1622
+
"rustls-native-certs",
1623
1623
+
"serde",
1624
1624
+
"serde_json",
1625
1625
+
"thiserror 1.0.69",
1626
1626
+
]
1627
1627
+
1628
1628
+
[[package]]
1629
1629
+
name = "rustc-hash"
1630
1630
+
version = "2.1.2"
1631
1631
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1632
1632
+
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
1633
1633
+
1634
1634
+
[[package]]
1635
1635
+
name = "rustix"
1636
1636
+
version = "0.38.44"
1637
1637
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1638
1638
+
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
1639
1639
+
dependencies = [
1640
1640
+
"bitflags 2.13.0",
1641
1641
+
"errno",
1642
1642
+
"libc",
1643
1643
+
"linux-raw-sys 0.4.15",
1644
1644
+
"windows-sys 0.59.0",
1645
1645
+
]
1646
1646
+
1647
1647
+
[[package]]
1648
1648
+
name = "rustix"
1649
1649
+
version = "1.1.4"
1650
1650
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1651
1651
+
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
1652
1652
+
dependencies = [
1653
1653
+
"bitflags 2.13.0",
1654
1654
+
"errno",
1655
1655
+
"libc",
1656
1656
+
"linux-raw-sys 0.12.1",
1657
1657
+
"windows-sys 0.61.2",
1658
1658
+
]
1659
1659
+
1660
1660
+
[[package]]
1661
1661
+
name = "rustls"
1662
1662
+
version = "0.23.41"
1663
1663
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1664
1664
+
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
1665
1665
+
dependencies = [
1666
1666
+
"aws-lc-rs",
1667
1667
+
"log",
1668
1668
+
"once_cell",
1669
1669
+
"ring",
1670
1670
+
"rustls-pki-types",
1671
1671
+
"rustls-webpki",
1672
1672
+
"subtle",
1673
1673
+
"zeroize",
1674
1674
+
]
1675
1675
+
1676
1676
+
[[package]]
1677
1677
+
name = "rustls-native-certs"
1678
1678
+
version = "0.8.4"
1679
1679
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1680
1680
+
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
1681
1681
+
dependencies = [
1682
1682
+
"openssl-probe",
1683
1683
+
"rustls-pki-types",
1684
1684
+
"schannel",
1685
1685
+
"security-framework",
1686
1686
+
]
1687
1687
+
1688
1688
+
[[package]]
1689
1689
+
name = "rustls-pki-types"
1690
1690
+
version = "1.15.0"
1691
1691
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1692
1692
+
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
1693
1693
+
dependencies = [
1694
1694
+
"web-time",
1695
1695
+
"zeroize",
1696
1696
+
]
1697
1697
+
1698
1698
+
[[package]]
1699
1699
+
name = "rustls-webpki"
1700
1700
+
version = "0.103.13"
1701
1701
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1702
1702
+
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
1703
1703
+
dependencies = [
1704
1704
+
"aws-lc-rs",
1705
1705
+
"ring",
1706
1706
+
"rustls-pki-types",
1707
1707
+
"untrusted",
1708
1708
+
]
1709
1709
+
1710
1710
+
[[package]]
1711
1711
+
name = "rustversion"
1712
1712
+
version = "1.0.22"
1713
1713
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1714
1714
+
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
1715
1715
+
1716
1716
+
[[package]]
1717
1717
+
name = "ryu"
1718
1718
+
version = "1.0.23"
1719
1719
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1720
1720
+
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
1721
1721
+
1722
1722
+
[[package]]
1723
1723
+
name = "schannel"
1724
1724
+
version = "0.1.29"
1725
1725
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1726
1726
+
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
1727
1727
+
dependencies = [
1728
1728
+
"windows-sys 0.61.2",
1729
1729
+
]
1730
1730
+
1731
1731
+
[[package]]
1732
1732
+
name = "scopeguard"
1733
1733
+
version = "1.2.0"
1734
1734
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1735
1735
+
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1736
1736
+
1737
1737
+
[[package]]
1738
1738
+
name = "security-framework"
1739
1739
+
version = "3.7.0"
1740
1740
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1741
1741
+
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
1742
1742
+
dependencies = [
1743
1743
+
"bitflags 2.13.0",
1744
1744
+
"core-foundation",
1745
1745
+
"core-foundation-sys",
1746
1746
+
"libc",
1747
1747
+
"security-framework-sys",
1748
1748
+
]
1749
1749
+
1750
1750
+
[[package]]
1751
1751
+
name = "security-framework-sys"
1752
1752
+
version = "2.17.0"
1753
1753
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1754
1754
+
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
1755
1755
+
dependencies = [
1756
1756
+
"core-foundation-sys",
1757
1757
+
"libc",
1758
1758
+
]
1759
1759
+
1760
1760
+
[[package]]
1761
1761
+
name = "serde"
1762
1762
+
version = "1.0.228"
1763
1763
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1764
1764
+
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
1765
1765
+
dependencies = [
1766
1766
+
"serde_core",
1767
1767
+
"serde_derive",
1768
1768
+
]
1769
1769
+
1770
1770
+
[[package]]
1771
1771
+
name = "serde_core"
1772
1772
+
version = "1.0.228"
1773
1773
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1774
1774
+
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
1775
1775
+
dependencies = [
1776
1776
+
"serde_derive",
1777
1777
+
]
1778
1778
+
1779
1779
+
[[package]]
1780
1780
+
name = "serde_derive"
1781
1781
+
version = "1.0.228"
1782
1782
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1783
1783
+
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
1784
1784
+
dependencies = [
1785
1785
+
"proc-macro2",
1786
1786
+
"quote",
1787
1787
+
"syn",
1788
1788
+
]
1789
1789
+
1790
1790
+
[[package]]
1791
1791
+
name = "serde_json"
1792
1792
+
version = "1.0.150"
1793
1793
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1794
1794
+
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
1795
1795
+
dependencies = [
1796
1796
+
"itoa",
1797
1797
+
"memchr",
1798
1798
+
"serde",
1799
1799
+
"serde_core",
1800
1800
+
"zmij",
1801
1801
+
]
1802
1802
+
1803
1803
+
[[package]]
1804
1804
+
name = "serde_spanned"
1805
1805
+
version = "0.6.9"
1806
1806
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1807
1807
+
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
1808
1808
+
dependencies = [
1809
1809
+
"serde",
1810
1810
+
]
1811
1811
+
1812
1812
+
[[package]]
1813
1813
+
name = "serde_urlencoded"
1814
1814
+
version = "0.7.1"
1815
1815
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1816
1816
+
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
1817
1817
+
dependencies = [
1818
1818
+
"form_urlencoded",
1819
1819
+
"itoa",
1820
1820
+
"ryu",
1821
1821
+
"serde",
1822
1822
+
]
1823
1823
+
1824
1824
+
[[package]]
1825
1825
+
name = "sharded-slab"
1826
1826
+
version = "0.1.7"
1827
1827
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1828
1828
+
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
1829
1829
+
dependencies = [
1830
1830
+
"lazy_static",
1831
1831
+
]
1832
1832
+
1833
1833
+
[[package]]
1834
1834
+
name = "shlex"
1835
1835
+
version = "2.0.1"
1836
1836
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1837
1837
+
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
1838
1838
+
1839
1839
+
[[package]]
1840
1840
+
name = "signal-hook"
1841
1841
+
version = "0.3.18"
1842
1842
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1843
1843
+
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
1844
1844
+
dependencies = [
1845
1845
+
"libc",
1846
1846
+
"signal-hook-registry",
1847
1847
+
]
1848
1848
+
1849
1849
+
[[package]]
1850
1850
+
name = "signal-hook-mio"
1851
1851
+
version = "0.2.5"
1852
1852
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1853
1853
+
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
1854
1854
+
dependencies = [
1855
1855
+
"libc",
1856
1856
+
"mio",
1857
1857
+
"signal-hook",
1858
1858
+
]
1859
1859
+
1860
1860
+
[[package]]
1861
1861
+
name = "signal-hook-registry"
1862
1862
+
version = "1.4.8"
1863
1863
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1864
1864
+
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
1865
1865
+
dependencies = [
1866
1866
+
"errno",
1867
1867
+
"libc",
1868
1868
+
]
1869
1869
+
1870
1870
+
[[package]]
1871
1871
+
name = "slab"
1872
1872
+
version = "0.4.12"
1873
1873
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1874
1874
+
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
1875
1875
+
1876
1876
+
[[package]]
1877
1877
+
name = "smallvec"
1878
1878
+
version = "1.15.2"
1879
1879
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1880
1880
+
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
1881
1881
+
1882
1882
+
[[package]]
1883
1883
+
name = "socket2"
1884
1884
+
version = "0.5.10"
1885
1885
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1886
1886
+
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
1887
1887
+
dependencies = [
1888
1888
+
"libc",
1889
1889
+
"windows-sys 0.52.0",
1890
1890
+
]
1891
1891
+
1892
1892
+
[[package]]
1893
1893
+
name = "socket2"
1894
1894
+
version = "0.6.4"
1895
1895
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1896
1896
+
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
1897
1897
+
dependencies = [
1898
1898
+
"libc",
1899
1899
+
"windows-sys 0.61.2",
1900
1900
+
]
1901
1901
+
1902
1902
+
[[package]]
1903
1903
+
name = "spin"
1904
1904
+
version = "0.9.8"
1905
1905
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1906
1906
+
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
1907
1907
+
dependencies = [
1908
1908
+
"lock_api",
1909
1909
+
]
1910
1910
+
1911
1911
+
[[package]]
1912
1912
+
name = "stable_deref_trait"
1913
1913
+
version = "1.2.1"
1914
1914
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1915
1915
+
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1916
1916
+
1917
1917
+
[[package]]
1918
1918
+
name = "static_assertions"
1919
1919
+
version = "1.1.0"
1920
1920
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1921
1921
+
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
1922
1922
+
1923
1923
+
[[package]]
1924
1924
+
name = "strsim"
1925
1925
+
version = "0.11.1"
1926
1926
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1927
1927
+
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
1928
1928
+
1929
1929
+
[[package]]
1930
1930
+
name = "strum"
1931
1931
+
version = "0.26.3"
1932
1932
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1933
1933
+
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
1934
1934
+
dependencies = [
1935
1935
+
"strum_macros",
1936
1936
+
]
1937
1937
+
1938
1938
+
[[package]]
1939
1939
+
name = "strum_macros"
1940
1940
+
version = "0.26.4"
1941
1941
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1942
1942
+
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
1943
1943
+
dependencies = [
1944
1944
+
"heck",
1945
1945
+
"proc-macro2",
1946
1946
+
"quote",
1947
1947
+
"rustversion",
1948
1948
+
"syn",
1949
1949
+
]
1950
1950
+
1951
1951
+
[[package]]
1952
1952
+
name = "subtle"
1953
1953
+
version = "2.6.1"
1954
1954
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1955
1955
+
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
1956
1956
+
1957
1957
+
[[package]]
1958
1958
+
name = "syn"
1959
1959
+
version = "2.0.118"
1960
1960
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1961
1961
+
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
1962
1962
+
dependencies = [
1963
1963
+
"proc-macro2",
1964
1964
+
"quote",
1965
1965
+
"unicode-ident",
1966
1966
+
]
1967
1967
+
1968
1968
+
[[package]]
1969
1969
+
name = "sync_wrapper"
1970
1970
+
version = "1.0.2"
1971
1971
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1972
1972
+
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
1973
1973
+
dependencies = [
1974
1974
+
"futures-core",
1975
1975
+
]
1976
1976
+
1977
1977
+
[[package]]
1978
1978
+
name = "synstructure"
1979
1979
+
version = "0.13.2"
1980
1980
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1981
1981
+
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
1982
1982
+
dependencies = [
1983
1983
+
"proc-macro2",
1984
1984
+
"quote",
1985
1985
+
"syn",
1986
1986
+
]
1987
1987
+
1988
1988
+
[[package]]
1989
1989
+
name = "tempfile"
1990
1990
+
version = "3.27.0"
1991
1991
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1992
1992
+
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
1993
1993
+
dependencies = [
1994
1994
+
"fastrand",
1995
1995
+
"getrandom 0.4.3",
1996
1996
+
"once_cell",
1997
1997
+
"rustix 1.1.4",
1998
1998
+
"windows-sys 0.61.2",
1999
1999
+
]
2000
2000
+
2001
2001
+
[[package]]
2002
2002
+
name = "thiserror"
2003
2003
+
version = "1.0.69"
2004
2004
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2005
2005
+
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
2006
2006
+
dependencies = [
2007
2007
+
"thiserror-impl 1.0.69",
2008
2008
+
]
2009
2009
+
2010
2010
+
[[package]]
2011
2011
+
name = "thiserror"
2012
2012
+
version = "2.0.18"
2013
2013
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2014
2014
+
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
2015
2015
+
dependencies = [
2016
2016
+
"thiserror-impl 2.0.18",
2017
2017
+
]
2018
2018
+
2019
2019
+
[[package]]
2020
2020
+
name = "thiserror-impl"
2021
2021
+
version = "1.0.69"
2022
2022
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2023
2023
+
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
2024
2024
+
dependencies = [
2025
2025
+
"proc-macro2",
2026
2026
+
"quote",
2027
2027
+
"syn",
2028
2028
+
]
2029
2029
+
2030
2030
+
[[package]]
2031
2031
+
name = "thiserror-impl"
2032
2032
+
version = "2.0.18"
2033
2033
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2034
2034
+
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
2035
2035
+
dependencies = [
2036
2036
+
"proc-macro2",
2037
2037
+
"quote",
2038
2038
+
"syn",
2039
2039
+
]
2040
2040
+
2041
2041
+
[[package]]
2042
2042
+
name = "thread_local"
2043
2043
+
version = "1.1.9"
2044
2044
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2045
2045
+
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
2046
2046
+
dependencies = [
2047
2047
+
"cfg-if",
2048
2048
+
]
2049
2049
+
2050
2050
+
[[package]]
2051
2051
+
name = "tinystr"
2052
2052
+
version = "0.8.3"
2053
2053
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2054
2054
+
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
2055
2055
+
dependencies = [
2056
2056
+
"displaydoc",
2057
2057
+
"zerovec",
2058
2058
+
]
2059
2059
+
2060
2060
+
[[package]]
2061
2061
+
name = "tinyvec"
2062
2062
+
version = "1.11.0"
2063
2063
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2064
2064
+
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
2065
2065
+
dependencies = [
2066
2066
+
"tinyvec_macros",
2067
2067
+
]
2068
2068
+
2069
2069
+
[[package]]
2070
2070
+
name = "tinyvec_macros"
2071
2071
+
version = "0.1.1"
2072
2072
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2073
2073
+
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
2074
2074
+
2075
2075
+
[[package]]
2076
2076
+
name = "tokio"
2077
2077
+
version = "1.52.3"
2078
2078
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2079
2079
+
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
2080
2080
+
dependencies = [
2081
2081
+
"bytes",
2082
2082
+
"libc",
2083
2083
+
"mio",
2084
2084
+
"parking_lot",
2085
2085
+
"pin-project-lite",
2086
2086
+
"signal-hook-registry",
2087
2087
+
"socket2 0.6.4",
2088
2088
+
"tokio-macros",
2089
2089
+
"windows-sys 0.61.2",
2090
2090
+
]
2091
2091
+
2092
2092
+
[[package]]
2093
2093
+
name = "tokio-macros"
2094
2094
+
version = "2.7.0"
2095
2095
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2096
2096
+
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
2097
2097
+
dependencies = [
2098
2098
+
"proc-macro2",
2099
2099
+
"quote",
2100
2100
+
"syn",
2101
2101
+
]
2102
2102
+
2103
2103
+
[[package]]
2104
2104
+
name = "tokio-rustls"
2105
2105
+
version = "0.26.4"
2106
2106
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2107
2107
+
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
2108
2108
+
dependencies = [
2109
2109
+
"rustls",
2110
2110
+
"tokio",
2111
2111
+
]
2112
2112
+
2113
2113
+
[[package]]
2114
2114
+
name = "toml"
2115
2115
+
version = "0.8.23"
2116
2116
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2117
2117
+
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
2118
2118
+
dependencies = [
2119
2119
+
"serde",
2120
2120
+
"serde_spanned",
2121
2121
+
"toml_datetime",
2122
2122
+
"toml_edit",
2123
2123
+
]
2124
2124
+
2125
2125
+
[[package]]
2126
2126
+
name = "toml_datetime"
2127
2127
+
version = "0.6.11"
2128
2128
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2129
2129
+
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
2130
2130
+
dependencies = [
2131
2131
+
"serde",
2132
2132
+
]
2133
2133
+
2134
2134
+
[[package]]
2135
2135
+
name = "toml_edit"
2136
2136
+
version = "0.22.27"
2137
2137
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2138
2138
+
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
2139
2139
+
dependencies = [
2140
2140
+
"indexmap",
2141
2141
+
"serde",
2142
2142
+
"serde_spanned",
2143
2143
+
"toml_datetime",
2144
2144
+
"toml_write",
2145
2145
+
"winnow",
2146
2146
+
]
2147
2147
+
2148
2148
+
[[package]]
2149
2149
+
name = "toml_write"
2150
2150
+
version = "0.1.2"
2151
2151
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2152
2152
+
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
2153
2153
+
2154
2154
+
[[package]]
2155
2155
+
name = "tower"
2156
2156
+
version = "0.5.3"
2157
2157
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2158
2158
+
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
2159
2159
+
dependencies = [
2160
2160
+
"futures-core",
2161
2161
+
"futures-util",
2162
2162
+
"pin-project-lite",
2163
2163
+
"sync_wrapper",
2164
2164
+
"tokio",
2165
2165
+
"tower-layer",
2166
2166
+
"tower-service",
2167
2167
+
]
2168
2168
+
2169
2169
+
[[package]]
2170
2170
+
name = "tower-http"
2171
2171
+
version = "0.6.11"
2172
2172
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2173
2173
+
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
2174
2174
+
dependencies = [
2175
2175
+
"bitflags 2.13.0",
2176
2176
+
"bytes",
2177
2177
+
"futures-util",
2178
2178
+
"http",
2179
2179
+
"http-body",
2180
2180
+
"pin-project-lite",
2181
2181
+
"tower",
2182
2182
+
"tower-layer",
2183
2183
+
"tower-service",
2184
2184
+
"url",
2185
2185
+
]
2186
2186
+
2187
2187
+
[[package]]
2188
2188
+
name = "tower-layer"
2189
2189
+
version = "0.3.3"
2190
2190
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2191
2191
+
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
2192
2192
+
2193
2193
+
[[package]]
2194
2194
+
name = "tower-service"
2195
2195
+
version = "0.3.3"
2196
2196
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2197
2197
+
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
2198
2198
+
2199
2199
+
[[package]]
2200
2200
+
name = "tracing"
2201
2201
+
version = "0.1.44"
2202
2202
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2203
2203
+
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
2204
2204
+
dependencies = [
2205
2205
+
"pin-project-lite",
2206
2206
+
"tracing-attributes",
2207
2207
+
"tracing-core",
2208
2208
+
]
2209
2209
+
2210
2210
+
[[package]]
2211
2211
+
name = "tracing-attributes"
2212
2212
+
version = "0.1.31"
2213
2213
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2214
2214
+
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
2215
2215
+
dependencies = [
2216
2216
+
"proc-macro2",
2217
2217
+
"quote",
2218
2218
+
"syn",
2219
2219
+
]
2220
2220
+
2221
2221
+
[[package]]
2222
2222
+
name = "tracing-core"
2223
2223
+
version = "0.1.36"
2224
2224
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2225
2225
+
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2226
2226
+
dependencies = [
2227
2227
+
"once_cell",
2228
2228
+
"valuable",
2229
2229
+
]
2230
2230
+
2231
2231
+
[[package]]
2232
2232
+
name = "tracing-log"
2233
2233
+
version = "0.2.0"
2234
2234
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2235
2235
+
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
2236
2236
+
dependencies = [
2237
2237
+
"log",
2238
2238
+
"once_cell",
2239
2239
+
"tracing-core",
2240
2240
+
]
2241
2241
+
2242
2242
+
[[package]]
2243
2243
+
name = "tracing-subscriber"
2244
2244
+
version = "0.3.23"
2245
2245
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2246
2246
+
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
2247
2247
+
dependencies = [
2248
2248
+
"matchers",
2249
2249
+
"nu-ansi-term",
2250
2250
+
"once_cell",
2251
2251
+
"regex-automata",
2252
2252
+
"sharded-slab",
2253
2253
+
"smallvec",
2254
2254
+
"thread_local",
2255
2255
+
"tracing",
2256
2256
+
"tracing-core",
2257
2257
+
"tracing-log",
2258
2258
+
]
2259
2259
+
2260
2260
+
[[package]]
2261
2261
+
name = "try-lock"
2262
2262
+
version = "0.2.5"
2263
2263
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2264
2264
+
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
2265
2265
+
2266
2266
+
[[package]]
2267
2267
+
name = "unicode-ident"
2268
2268
+
version = "1.0.24"
2269
2269
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2270
2270
+
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
2271
2271
+
2272
2272
+
[[package]]
2273
2273
+
name = "unicode-segmentation"
2274
2274
+
version = "1.13.3"
2275
2275
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2276
2276
+
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
2277
2277
+
2278
2278
+
[[package]]
2279
2279
+
name = "unicode-truncate"
2280
2280
+
version = "1.1.0"
2281
2281
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2282
2282
+
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
2283
2283
+
dependencies = [
2284
2284
+
"itertools",
2285
2285
+
"unicode-segmentation",
2286
2286
+
"unicode-width 0.1.14",
2287
2287
+
]
2288
2288
+
2289
2289
+
[[package]]
2290
2290
+
name = "unicode-width"
2291
2291
+
version = "0.1.14"
2292
2292
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2293
2293
+
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
2294
2294
+
2295
2295
+
[[package]]
2296
2296
+
name = "unicode-width"
2297
2297
+
version = "0.2.0"
2298
2298
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2299
2299
+
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
2300
2300
+
2301
2301
+
[[package]]
2302
2302
+
name = "untrusted"
2303
2303
+
version = "0.9.0"
2304
2304
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2305
2305
+
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
2306
2306
+
2307
2307
+
[[package]]
2308
2308
+
name = "url"
2309
2309
+
version = "2.5.8"
2310
2310
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2311
2311
+
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
2312
2312
+
dependencies = [
2313
2313
+
"form_urlencoded",
2314
2314
+
"idna",
2315
2315
+
"percent-encoding",
2316
2316
+
"serde",
2317
2317
+
]
2318
2318
+
2319
2319
+
[[package]]
2320
2320
+
name = "utf8_iter"
2321
2321
+
version = "1.0.4"
2322
2322
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2323
2323
+
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
2324
2324
+
2325
2325
+
[[package]]
2326
2326
+
name = "utf8parse"
2327
2327
+
version = "0.2.2"
2328
2328
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2329
2329
+
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
2330
2330
+
2331
2331
+
[[package]]
2332
2332
+
name = "uuid"
2333
2333
+
version = "1.23.4"
2334
2334
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2335
2335
+
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
2336
2336
+
dependencies = [
2337
2337
+
"getrandom 0.4.3",
2338
2338
+
"js-sys",
2339
2339
+
"wasm-bindgen",
2340
2340
+
]
2341
2341
+
2342
2342
+
[[package]]
2343
2343
+
name = "valuable"
2344
2344
+
version = "0.1.1"
2345
2345
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2346
2346
+
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
2347
2347
+
2348
2348
+
[[package]]
2349
2349
+
name = "want"
2350
2350
+
version = "0.3.1"
2351
2351
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2352
2352
+
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
2353
2353
+
dependencies = [
2354
2354
+
"try-lock",
2355
2355
+
]
2356
2356
+
2357
2357
+
[[package]]
2358
2358
+
name = "wasi"
2359
2359
+
version = "0.11.1+wasi-snapshot-preview1"
2360
2360
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2361
2361
+
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
2362
2362
+
2363
2363
+
[[package]]
2364
2364
+
name = "wasip2"
2365
2365
+
version = "1.0.4+wasi-0.2.12"
2366
2366
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2367
2367
+
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
2368
2368
+
dependencies = [
2369
2369
+
"wit-bindgen",
2370
2370
+
]
2371
2371
+
2372
2372
+
[[package]]
2373
2373
+
name = "wasite"
2374
2374
+
version = "0.1.0"
2375
2375
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2376
2376
+
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
2377
2377
+
2378
2378
+
[[package]]
2379
2379
+
name = "wasm-bindgen"
2380
2380
+
version = "0.2.126"
2381
2381
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2382
2382
+
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
2383
2383
+
dependencies = [
2384
2384
+
"cfg-if",
2385
2385
+
"once_cell",
2386
2386
+
"rustversion",
2387
2387
+
"wasm-bindgen-macro",
2388
2388
+
"wasm-bindgen-shared",
2389
2389
+
]
2390
2390
+
2391
2391
+
[[package]]
2392
2392
+
name = "wasm-bindgen-futures"
2393
2393
+
version = "0.4.76"
2394
2394
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2395
2395
+
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
2396
2396
+
dependencies = [
2397
2397
+
"js-sys",
2398
2398
+
"wasm-bindgen",
2399
2399
+
]
2400
2400
+
2401
2401
+
[[package]]
2402
2402
+
name = "wasm-bindgen-macro"
2403
2403
+
version = "0.2.126"
2404
2404
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2405
2405
+
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
2406
2406
+
dependencies = [
2407
2407
+
"quote",
2408
2408
+
"wasm-bindgen-macro-support",
2409
2409
+
]
2410
2410
+
2411
2411
+
[[package]]
2412
2412
+
name = "wasm-bindgen-macro-support"
2413
2413
+
version = "0.2.126"
2414
2414
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2415
2415
+
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
2416
2416
+
dependencies = [
2417
2417
+
"bumpalo",
2418
2418
+
"proc-macro2",
2419
2419
+
"quote",
2420
2420
+
"syn",
2421
2421
+
"wasm-bindgen-shared",
2422
2422
+
]
2423
2423
+
2424
2424
+
[[package]]
2425
2425
+
name = "wasm-bindgen-shared"
2426
2426
+
version = "0.2.126"
2427
2427
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2428
2428
+
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
2429
2429
+
dependencies = [
2430
2430
+
"unicode-ident",
2431
2431
+
]
2432
2432
+
2433
2433
+
[[package]]
2434
2434
+
name = "web-sys"
2435
2435
+
version = "0.3.103"
2436
2436
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2437
2437
+
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
2438
2438
+
dependencies = [
2439
2439
+
"js-sys",
2440
2440
+
"wasm-bindgen",
2441
2441
+
]
2442
2442
+
2443
2443
+
[[package]]
2444
2444
+
name = "web-time"
2445
2445
+
version = "1.1.0"
2446
2446
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2447
2447
+
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
2448
2448
+
dependencies = [
2449
2449
+
"js-sys",
2450
2450
+
"wasm-bindgen",
2451
2451
+
]
2452
2452
+
2453
2453
+
[[package]]
2454
2454
+
name = "webpki-roots"
2455
2455
+
version = "1.0.8"
2456
2456
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2457
2457
+
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
2458
2458
+
dependencies = [
2459
2459
+
"rustls-pki-types",
2460
2460
+
]
2461
2461
+
2462
2462
+
[[package]]
2463
2463
+
name = "which"
2464
2464
+
version = "4.4.2"
2465
2465
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2466
2466
+
checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7"
2467
2467
+
dependencies = [
2468
2468
+
"either",
2469
2469
+
"home",
2470
2470
+
"once_cell",
2471
2471
+
"rustix 0.38.44",
2472
2472
+
]
2473
2473
+
2474
2474
+
[[package]]
2475
2475
+
name = "whoami"
2476
2476
+
version = "1.6.1"
2477
2477
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2478
2478
+
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
2479
2479
+
dependencies = [
2480
2480
+
"libredox",
2481
2481
+
"wasite",
2482
2482
+
"web-sys",
2483
2483
+
]
2484
2484
+
2485
2485
+
[[package]]
2486
2486
+
name = "winapi"
2487
2487
+
version = "0.3.9"
2488
2488
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2489
2489
+
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
2490
2490
+
dependencies = [
2491
2491
+
"winapi-i686-pc-windows-gnu",
2492
2492
+
"winapi-x86_64-pc-windows-gnu",
2493
2493
+
]
2494
2494
+
2495
2495
+
[[package]]
2496
2496
+
name = "winapi-i686-pc-windows-gnu"
2497
2497
+
version = "0.4.0"
2498
2498
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2499
2499
+
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
2500
2500
+
2501
2501
+
[[package]]
2502
2502
+
name = "winapi-x86_64-pc-windows-gnu"
2503
2503
+
version = "0.4.0"
2504
2504
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2505
2505
+
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
2506
2506
+
2507
2507
+
[[package]]
2508
2508
+
name = "windows-core"
2509
2509
+
version = "0.62.2"
2510
2510
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2511
2511
+
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
2512
2512
+
dependencies = [
2513
2513
+
"windows-implement",
2514
2514
+
"windows-interface",
2515
2515
+
"windows-link",
2516
2516
+
"windows-result",
2517
2517
+
"windows-strings",
2518
2518
+
]
2519
2519
+
2520
2520
+
[[package]]
2521
2521
+
name = "windows-implement"
2522
2522
+
version = "0.60.2"
2523
2523
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2524
2524
+
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
2525
2525
+
dependencies = [
2526
2526
+
"proc-macro2",
2527
2527
+
"quote",
2528
2528
+
"syn",
2529
2529
+
]
2530
2530
+
2531
2531
+
[[package]]
2532
2532
+
name = "windows-interface"
2533
2533
+
version = "0.59.3"
2534
2534
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2535
2535
+
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
2536
2536
+
dependencies = [
2537
2537
+
"proc-macro2",
2538
2538
+
"quote",
2539
2539
+
"syn",
2540
2540
+
]
2541
2541
+
2542
2542
+
[[package]]
2543
2543
+
name = "windows-link"
2544
2544
+
version = "0.2.1"
2545
2545
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2546
2546
+
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
2547
2547
+
2548
2548
+
[[package]]
2549
2549
+
name = "windows-result"
2550
2550
+
version = "0.4.1"
2551
2551
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2552
2552
+
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
2553
2553
+
dependencies = [
2554
2554
+
"windows-link",
2555
2555
+
]
2556
2556
+
2557
2557
+
[[package]]
2558
2558
+
name = "windows-strings"
2559
2559
+
version = "0.5.1"
2560
2560
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2561
2561
+
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
2562
2562
+
dependencies = [
2563
2563
+
"windows-link",
2564
2564
+
]
2565
2565
+
2566
2566
+
[[package]]
2567
2567
+
name = "windows-sys"
2568
2568
+
version = "0.48.0"
2569
2569
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2570
2570
+
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
2571
2571
+
dependencies = [
2572
2572
+
"windows-targets 0.48.5",
2573
2573
+
]
2574
2574
+
2575
2575
+
[[package]]
2576
2576
+
name = "windows-sys"
2577
2577
+
version = "0.52.0"
2578
2578
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2579
2579
+
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
2580
2580
+
dependencies = [
2581
2581
+
"windows-targets 0.52.6",
2582
2582
+
]
2583
2583
+
2584
2584
+
[[package]]
2585
2585
+
name = "windows-sys"
2586
2586
+
version = "0.59.0"
2587
2587
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2588
2588
+
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
2589
2589
+
dependencies = [
2590
2590
+
"windows-targets 0.52.6",
2591
2591
+
]
2592
2592
+
2593
2593
+
[[package]]
2594
2594
+
name = "windows-sys"
2595
2595
+
version = "0.60.2"
2596
2596
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2597
2597
+
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
2598
2598
+
dependencies = [
2599
2599
+
"windows-targets 0.53.5",
2600
2600
+
]
2601
2601
+
2602
2602
+
[[package]]
2603
2603
+
name = "windows-sys"
2604
2604
+
version = "0.61.2"
2605
2605
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2606
2606
+
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
2607
2607
+
dependencies = [
2608
2608
+
"windows-link",
2609
2609
+
]
2610
2610
+
2611
2611
+
[[package]]
2612
2612
+
name = "windows-targets"
2613
2613
+
version = "0.48.5"
2614
2614
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2615
2615
+
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
2616
2616
+
dependencies = [
2617
2617
+
"windows_aarch64_gnullvm 0.48.5",
2618
2618
+
"windows_aarch64_msvc 0.48.5",
2619
2619
+
"windows_i686_gnu 0.48.5",
2620
2620
+
"windows_i686_msvc 0.48.5",
2621
2621
+
"windows_x86_64_gnu 0.48.5",
2622
2622
+
"windows_x86_64_gnullvm 0.48.5",
2623
2623
+
"windows_x86_64_msvc 0.48.5",
2624
2624
+
]
2625
2625
+
2626
2626
+
[[package]]
2627
2627
+
name = "windows-targets"
2628
2628
+
version = "0.52.6"
2629
2629
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2630
2630
+
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
2631
2631
+
dependencies = [
2632
2632
+
"windows_aarch64_gnullvm 0.52.6",
2633
2633
+
"windows_aarch64_msvc 0.52.6",
2634
2634
+
"windows_i686_gnu 0.52.6",
2635
2635
+
"windows_i686_gnullvm 0.52.6",
2636
2636
+
"windows_i686_msvc 0.52.6",
2637
2637
+
"windows_x86_64_gnu 0.52.6",
2638
2638
+
"windows_x86_64_gnullvm 0.52.6",
2639
2639
+
"windows_x86_64_msvc 0.52.6",
2640
2640
+
]
2641
2641
+
2642
2642
+
[[package]]
2643
2643
+
name = "windows-targets"
2644
2644
+
version = "0.53.5"
2645
2645
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2646
2646
+
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
2647
2647
+
dependencies = [
2648
2648
+
"windows-link",
2649
2649
+
"windows_aarch64_gnullvm 0.53.1",
2650
2650
+
"windows_aarch64_msvc 0.53.1",
2651
2651
+
"windows_i686_gnu 0.53.1",
2652
2652
+
"windows_i686_gnullvm 0.53.1",
2653
2653
+
"windows_i686_msvc 0.53.1",
2654
2654
+
"windows_x86_64_gnu 0.53.1",
2655
2655
+
"windows_x86_64_gnullvm 0.53.1",
2656
2656
+
"windows_x86_64_msvc 0.53.1",
2657
2657
+
]
2658
2658
+
2659
2659
+
[[package]]
2660
2660
+
name = "windows_aarch64_gnullvm"
2661
2661
+
version = "0.48.5"
2662
2662
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2663
2663
+
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
2664
2664
+
2665
2665
+
[[package]]
2666
2666
+
name = "windows_aarch64_gnullvm"
2667
2667
+
version = "0.52.6"
2668
2668
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2669
2669
+
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
2670
2670
+
2671
2671
+
[[package]]
2672
2672
+
name = "windows_aarch64_gnullvm"
2673
2673
+
version = "0.53.1"
2674
2674
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2675
2675
+
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
2676
2676
+
2677
2677
+
[[package]]
2678
2678
+
name = "windows_aarch64_msvc"
2679
2679
+
version = "0.48.5"
2680
2680
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2681
2681
+
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
2682
2682
+
2683
2683
+
[[package]]
2684
2684
+
name = "windows_aarch64_msvc"
2685
2685
+
version = "0.52.6"
2686
2686
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2687
2687
+
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
2688
2688
+
2689
2689
+
[[package]]
2690
2690
+
name = "windows_aarch64_msvc"
2691
2691
+
version = "0.53.1"
2692
2692
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2693
2693
+
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
2694
2694
+
2695
2695
+
[[package]]
2696
2696
+
name = "windows_i686_gnu"
2697
2697
+
version = "0.48.5"
2698
2698
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2699
2699
+
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
2700
2700
+
2701
2701
+
[[package]]
2702
2702
+
name = "windows_i686_gnu"
2703
2703
+
version = "0.52.6"
2704
2704
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2705
2705
+
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
2706
2706
+
2707
2707
+
[[package]]
2708
2708
+
name = "windows_i686_gnu"
2709
2709
+
version = "0.53.1"
2710
2710
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2711
2711
+
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
2712
2712
+
2713
2713
+
[[package]]
2714
2714
+
name = "windows_i686_gnullvm"
2715
2715
+
version = "0.52.6"
2716
2716
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2717
2717
+
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
2718
2718
+
2719
2719
+
[[package]]
2720
2720
+
name = "windows_i686_gnullvm"
2721
2721
+
version = "0.53.1"
2722
2722
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2723
2723
+
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
2724
2724
+
2725
2725
+
[[package]]
2726
2726
+
name = "windows_i686_msvc"
2727
2727
+
version = "0.48.5"
2728
2728
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2729
2729
+
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
2730
2730
+
2731
2731
+
[[package]]
2732
2732
+
name = "windows_i686_msvc"
2733
2733
+
version = "0.52.6"
2734
2734
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2735
2735
+
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
2736
2736
+
2737
2737
+
[[package]]
2738
2738
+
name = "windows_i686_msvc"
2739
2739
+
version = "0.53.1"
2740
2740
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2741
2741
+
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
2742
2742
+
2743
2743
+
[[package]]
2744
2744
+
name = "windows_x86_64_gnu"
2745
2745
+
version = "0.48.5"
2746
2746
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2747
2747
+
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
2748
2748
+
2749
2749
+
[[package]]
2750
2750
+
name = "windows_x86_64_gnu"
2751
2751
+
version = "0.52.6"
2752
2752
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2753
2753
+
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
2754
2754
+
2755
2755
+
[[package]]
2756
2756
+
name = "windows_x86_64_gnu"
2757
2757
+
version = "0.53.1"
2758
2758
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2759
2759
+
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
2760
2760
+
2761
2761
+
[[package]]
2762
2762
+
name = "windows_x86_64_gnullvm"
2763
2763
+
version = "0.48.5"
2764
2764
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2765
2765
+
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
2766
2766
+
2767
2767
+
[[package]]
2768
2768
+
name = "windows_x86_64_gnullvm"
2769
2769
+
version = "0.52.6"
2770
2770
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2771
2771
+
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
2772
2772
+
2773
2773
+
[[package]]
2774
2774
+
name = "windows_x86_64_gnullvm"
2775
2775
+
version = "0.53.1"
2776
2776
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2777
2777
+
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
2778
2778
+
2779
2779
+
[[package]]
2780
2780
+
name = "windows_x86_64_msvc"
2781
2781
+
version = "0.48.5"
2782
2782
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2783
2783
+
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
2784
2784
+
2785
2785
+
[[package]]
2786
2786
+
name = "windows_x86_64_msvc"
2787
2787
+
version = "0.52.6"
2788
2788
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2789
2789
+
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
2790
2790
+
2791
2791
+
[[package]]
2792
2792
+
name = "windows_x86_64_msvc"
2793
2793
+
version = "0.53.1"
2794
2794
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2795
2795
+
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
2796
2796
+
2797
2797
+
[[package]]
2798
2798
+
name = "winnow"
2799
2799
+
version = "0.7.15"
2800
2800
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2801
2801
+
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
2802
2802
+
dependencies = [
2803
2803
+
"memchr",
2804
2804
+
]
2805
2805
+
2806
2806
+
[[package]]
2807
2807
+
name = "wit-bindgen"
2808
2808
+
version = "0.57.1"
2809
2809
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2810
2810
+
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
2811
2811
+
2812
2812
+
[[package]]
2813
2813
+
name = "writeable"
2814
2814
+
version = "0.6.3"
2815
2815
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2816
2816
+
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
2817
2817
+
2818
2818
+
[[package]]
2819
2819
+
name = "yoke"
2820
2820
+
version = "0.8.3"
2821
2821
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2822
2822
+
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
2823
2823
+
dependencies = [
2824
2824
+
"stable_deref_trait",
2825
2825
+
"yoke-derive",
2826
2826
+
"zerofrom",
2827
2827
+
]
2828
2828
+
2829
2829
+
[[package]]
2830
2830
+
name = "yoke-derive"
2831
2831
+
version = "0.8.2"
2832
2832
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2833
2833
+
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
2834
2834
+
dependencies = [
2835
2835
+
"proc-macro2",
2836
2836
+
"quote",
2837
2837
+
"syn",
2838
2838
+
"synstructure",
2839
2839
+
]
2840
2840
+
2841
2841
+
[[package]]
2842
2842
+
name = "zerocopy"
2843
2843
+
version = "0.8.52"
2844
2844
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2845
2845
+
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
2846
2846
+
dependencies = [
2847
2847
+
"zerocopy-derive",
2848
2848
+
]
2849
2849
+
2850
2850
+
[[package]]
2851
2851
+
name = "zerocopy-derive"
2852
2852
+
version = "0.8.52"
2853
2853
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2854
2854
+
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
2855
2855
+
dependencies = [
2856
2856
+
"proc-macro2",
2857
2857
+
"quote",
2858
2858
+
"syn",
2859
2859
+
]
2860
2860
+
2861
2861
+
[[package]]
2862
2862
+
name = "zerofrom"
2863
2863
+
version = "0.1.8"
2864
2864
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2865
2865
+
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
2866
2866
+
dependencies = [
2867
2867
+
"zerofrom-derive",
2868
2868
+
]
2869
2869
+
2870
2870
+
[[package]]
2871
2871
+
name = "zerofrom-derive"
2872
2872
+
version = "0.1.7"
2873
2873
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2874
2874
+
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
2875
2875
+
dependencies = [
2876
2876
+
"proc-macro2",
2877
2877
+
"quote",
2878
2878
+
"syn",
2879
2879
+
"synstructure",
2880
2880
+
]
2881
2881
+
2882
2882
+
[[package]]
2883
2883
+
name = "zeroize"
2884
2884
+
version = "1.9.0"
2885
2885
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2886
2886
+
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
2887
2887
+
2888
2888
+
[[package]]
2889
2889
+
name = "zerotrie"
2890
2890
+
version = "0.2.4"
2891
2891
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2892
2892
+
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
2893
2893
+
dependencies = [
2894
2894
+
"displaydoc",
2895
2895
+
"yoke",
2896
2896
+
"zerofrom",
2897
2897
+
]
2898
2898
+
2899
2899
+
[[package]]
2900
2900
+
name = "zerovec"
2901
2901
+
version = "0.11.6"
2902
2902
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2903
2903
+
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
2904
2904
+
dependencies = [
2905
2905
+
"yoke",
2906
2906
+
"zerofrom",
2907
2907
+
"zerovec-derive",
2908
2908
+
]
2909
2909
+
2910
2910
+
[[package]]
2911
2911
+
name = "zerovec-derive"
2912
2912
+
version = "0.11.3"
2913
2913
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2914
2914
+
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
2915
2915
+
dependencies = [
2916
2916
+
"proc-macro2",
2917
2917
+
"quote",
2918
2918
+
"syn",
2919
2919
+
]
2920
2920
+
2921
2921
+
[[package]]
2922
2922
+
name = "zmij"
2923
2923
+
version = "1.0.21"
2924
2924
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2925
2925
+
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
···
1
1
+
[workspace]
2
2
+
members = ["crates/*"]
3
3
+
resolver = "2"
4
4
+
5
5
+
[workspace.package]
6
6
+
version = "0.1.0"
7
7
+
authors = ["Tsiry Sandratraina <tsiry.sndr@rocksky.app>"]
8
8
+
edition = "2021"
9
9
+
license = "MPL-2.0"
10
10
+
repository = "https://github.com/tsirysndr/fin"
11
11
+
12
12
+
[workspace.dependencies]
13
13
+
anyhow = "1"
14
14
+
thiserror = "1"
15
15
+
serde = { version = "1", features = ["derive"] }
16
16
+
serde_json = "1"
17
17
+
toml = "0.8"
18
18
+
tokio = { version = "1", features = ["full"] }
19
19
+
tokio-util = "0.7"
20
20
+
futures = "0.3"
21
21
+
async-trait = "0.1"
22
22
+
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
23
23
+
url = "2"
24
24
+
uuid = { version = "1", features = ["v4"] }
25
25
+
chrono = { version = "0.4", features = ["serde"] }
26
26
+
tracing = "0.1"
27
27
+
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
28
28
+
directories = "5"
29
29
+
ratatui = "0.29"
30
30
+
crossterm = { version = "0.28", features = ["event-stream"] }
31
31
+
clap = { version = "4", features = ["derive", "env"] }
32
32
+
mdns-sd = "0.11"
33
33
+
rust_cast = "0.20"
34
34
+
parking_lot = "0.12"
35
35
+
unicode-width = "0.2"
36
36
+
percent-encoding = "2"
37
37
+
whoami = "1"
38
38
+
rpassword = "7"
39
39
+
40
40
+
fin-config = { path = "crates/fin-config" }
41
41
+
fin-jellyfin = { path = "crates/fin-jellyfin" }
42
42
+
fin-player = { path = "crates/fin-player" }
43
43
+
fin-tui = { path = "crates/fin-tui" }
44
44
+
45
45
+
[profile.release]
46
46
+
opt-level = 3
47
47
+
lto = "thin"
48
48
+
strip = true
···
1
1
+
Mozilla Public License Version 2.0
2
2
+
==================================
3
3
+
4
4
+
1. Definitions
5
5
+
--------------
6
6
+
7
7
+
1.1. "Contributor"
8
8
+
means each individual or legal entity that creates, contributes to
9
9
+
the creation of, or owns Covered Software.
10
10
+
11
11
+
1.2. "Contributor Version"
12
12
+
means the combination of the Contributions of others (if any) used
13
13
+
by a Contributor and that particular Contributor's Contribution.
14
14
+
15
15
+
1.3. "Contribution"
16
16
+
means Covered Software of a particular Contributor.
17
17
+
18
18
+
1.4. "Covered Software"
19
19
+
means Source Code Form to which the initial Contributor has attached
20
20
+
the notice in Exhibit A, the Executable Form of such Source Code
21
21
+
Form, and Modifications of such Source Code Form, in each case
22
22
+
including portions thereof.
23
23
+
24
24
+
1.5. "Incompatible With Secondary Licenses"
25
25
+
means
26
26
+
27
27
+
(a) that the initial Contributor has attached the notice described
28
28
+
in Exhibit B to the Covered Software; or
29
29
+
30
30
+
(b) that the Covered Software was made available under the terms of
31
31
+
version 1.1 or earlier of the License, but not also under the
32
32
+
terms of a Secondary License.
33
33
+
34
34
+
1.6. "Executable Form"
35
35
+
means any form of the work other than Source Code Form.
36
36
+
37
37
+
1.7. "Larger Work"
38
38
+
means a work that combines Covered Software with other material, in
39
39
+
a separate file or files, that is not Covered Software.
40
40
+
41
41
+
1.8. "License"
42
42
+
means this document.
43
43
+
44
44
+
1.9. "Licensable"
45
45
+
means having the right to grant, to the maximum extent possible,
46
46
+
whether at the time of the initial grant or subsequently, any and
47
47
+
all of the rights conveyed by this License.
48
48
+
49
49
+
1.10. "Modifications"
50
50
+
means any of the following:
51
51
+
52
52
+
(a) any file in Source Code Form that results from an addition to,
53
53
+
deletion from, or modification of the contents of Covered
54
54
+
Software; or
55
55
+
56
56
+
(b) any new file in Source Code Form that contains any Covered
57
57
+
Software.
58
58
+
59
59
+
1.11. "Patent Claims" of a Contributor
60
60
+
means any patent claim(s), including without limitation, method,
61
61
+
process, and apparatus claims, in any patent Licensable by such
62
62
+
Contributor that would be infringed, but for the grant of the
63
63
+
License, by the making, using, selling, offering for sale, having
64
64
+
made, import, or transfer of either its Contributions or its
65
65
+
Contributor Version.
66
66
+
67
67
+
1.12. "Secondary License"
68
68
+
means either the GNU General Public License, Version 2.0, the GNU
69
69
+
Lesser General Public License, Version 2.1, the GNU Affero General
70
70
+
Public License, Version 3.0, or any later versions of those
71
71
+
licenses.
72
72
+
73
73
+
1.13. "Source Code Form"
74
74
+
means the form of the work preferred for making modifications.
75
75
+
76
76
+
1.14. "You" (or "Your")
77
77
+
means an individual or a legal entity exercising rights under this
78
78
+
License. For legal entities, "You" includes any entity that
79
79
+
controls, is controlled by, or is under common control with You. For
80
80
+
purposes of this definition, "control" means (a) the power, direct
81
81
+
or indirect, to cause the direction or management of such entity,
82
82
+
whether by contract or otherwise, or (b) ownership of more than
83
83
+
fifty percent (50%) of the outstanding shares or beneficial
84
84
+
ownership of such entity.
85
85
+
86
86
+
2. License Grants and Conditions
87
87
+
--------------------------------
88
88
+
89
89
+
2.1. Grants
90
90
+
91
91
+
Each Contributor hereby grants You a world-wide, royalty-free,
92
92
+
non-exclusive license:
93
93
+
94
94
+
(a) under intellectual property rights (other than patent or trademark)
95
95
+
Licensable by such Contributor to use, reproduce, make available,
96
96
+
modify, display, perform, distribute, and otherwise exploit its
97
97
+
Contributions, either on an unmodified basis, with Modifications, or
98
98
+
as part of a Larger Work; and
99
99
+
100
100
+
(b) under Patent Claims of such Contributor to make, use, sell, offer
101
101
+
for sale, have made, import, and otherwise transfer either its
102
102
+
Contributions or its Contributor Version.
103
103
+
104
104
+
2.2. Effective Date
105
105
+
106
106
+
The licenses granted in Section 2.1 with respect to any Contribution
107
107
+
become effective for each Contribution on the date the Contributor first
108
108
+
distributes such Contribution.
109
109
+
110
110
+
2.3. Limitations on Grant Scope
111
111
+
112
112
+
The licenses granted in this Section 2 are the only rights granted under
113
113
+
this License. No additional rights or licenses will be implied from the
114
114
+
distribution or licensing of Covered Software under this License.
115
115
+
Notwithstanding Section 2.1(b) above, no patent license is granted by a
116
116
+
Contributor:
117
117
+
118
118
+
(a) for any code that a Contributor has removed from Covered Software;
119
119
+
or
120
120
+
121
121
+
(b) for infringements caused by: (i) Your and any other third party's
122
122
+
modifications of Covered Software, or (ii) the combination of its
123
123
+
Contributions with other software (except as part of its Contributor
124
124
+
Version); or
125
125
+
126
126
+
(c) under Patent Claims infringed by Covered Software in the absence of
127
127
+
its Contributions.
128
128
+
129
129
+
This License does not grant any rights in the trademarks, service marks,
130
130
+
or logos of any Contributor (except as may be necessary to comply with
131
131
+
the notice requirements in Section 3.4).
132
132
+
133
133
+
2.4. Subsequent Licenses
134
134
+
135
135
+
No Contributor makes additional grants as a result of Your choice to
136
136
+
distribute the Covered Software under a subsequent version of this
137
137
+
License (see Section 10.2) or under the terms of a Secondary License (if
138
138
+
permitted under the terms of Section 3.3).
139
139
+
140
140
+
2.5. Representation
141
141
+
142
142
+
Each Contributor represents that the Contributor believes its
143
143
+
Contributions are its original creation(s) or it has sufficient rights
144
144
+
to grant the rights to its Contributions conveyed by this License.
145
145
+
146
146
+
2.6. Fair Use
147
147
+
148
148
+
This License is not intended to limit any rights You have under
149
149
+
applicable copyright doctrines of fair use, fair dealing, or other
150
150
+
equivalents.
151
151
+
152
152
+
2.7. Conditions
153
153
+
154
154
+
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
155
155
+
in Section 2.1.
156
156
+
157
157
+
3. Responsibilities
158
158
+
-------------------
159
159
+
160
160
+
3.1. Distribution of Source Form
161
161
+
162
162
+
All distribution of Covered Software in Source Code Form, including any
163
163
+
Modifications that You create or to which You contribute, must be under
164
164
+
the terms of this License. You must inform recipients that the Source
165
165
+
Code Form of the Covered Software is governed by the terms of this
166
166
+
License, and how they can obtain a copy of this License. You may not
167
167
+
attempt to alter or restrict the recipients' rights in the Source Code
168
168
+
Form.
169
169
+
170
170
+
3.2. Distribution of Executable Form
171
171
+
172
172
+
If You distribute Covered Software in Executable Form then:
173
173
+
174
174
+
(a) such Covered Software must also be made available in Source Code
175
175
+
Form, as described in Section 3.1, and You must inform recipients of
176
176
+
the Executable Form how they can obtain a copy of such Source Code
177
177
+
Form by reasonable means in a timely manner, at a charge no more
178
178
+
than the cost of distribution to the recipient; and
179
179
+
180
180
+
(b) You may distribute such Executable Form under the terms of this
181
181
+
License, or sublicense it under different terms, provided that the
182
182
+
license for the Executable Form does not attempt to limit or alter
183
183
+
the recipients' rights in the Source Code Form under this License.
184
184
+
185
185
+
3.3. Distribution of a Larger Work
186
186
+
187
187
+
You may create and distribute a Larger Work under terms of Your choice,
188
188
+
provided that You also comply with the requirements of this License for
189
189
+
the Covered Software. If the Larger Work is a combination of Covered
190
190
+
Software with a work governed by one or more Secondary Licenses, and the
191
191
+
Covered Software is not Incompatible With Secondary Licenses, this
192
192
+
License permits You to additionally distribute such Covered Software
193
193
+
under the terms of such Secondary License(s), so that the recipient of
194
194
+
the Larger Work may, at their option, further distribute the Covered
195
195
+
Software under the terms of either this License or such Secondary
196
196
+
License(s).
197
197
+
198
198
+
3.4. Notices
199
199
+
200
200
+
You may not remove or alter the substance of any license notices
201
201
+
(including copyright notices, patent notices, disclaimers of warranty,
202
202
+
or limitations of liability) contained within the Source Code Form of
203
203
+
the Covered Software, except that You may alter any license notices to
204
204
+
the extent required to remedy known factual inaccuracies.
205
205
+
206
206
+
3.5. Application of Additional Terms
207
207
+
208
208
+
You may choose to offer, and to charge a fee for, warranty, support,
209
209
+
indemnity or liability obligations to one or more recipients of Covered
210
210
+
Software. However, You may do so only on Your own behalf, and not on
211
211
+
behalf of any Contributor. You must make it absolutely clear that any
212
212
+
such warranty, support, indemnity, or liability obligation is offered by
213
213
+
You alone, and You hereby agree to indemnify every Contributor for any
214
214
+
liability incurred by such Contributor as a result of warranty, support,
215
215
+
indemnity or liability terms You offer. You may include additional
216
216
+
disclaimers of warranty and limitations of liability specific to any
217
217
+
jurisdiction.
218
218
+
219
219
+
4. Inability to Comply Due to Statute or Regulation
220
220
+
---------------------------------------------------
221
221
+
222
222
+
If it is impossible for You to comply with any of the terms of this
223
223
+
License with respect to some or all of the Covered Software due to
224
224
+
statute, judicial order, or regulation then You must: (a) comply with
225
225
+
the terms of this License to the maximum extent possible; and (b)
226
226
+
describe the limitations and the code they affect. Such description must
227
227
+
be placed in a text file included with all distributions of the Covered
228
228
+
Software under this License. Except to the extent prohibited by statute
229
229
+
or regulation, such description must be sufficiently detailed for a
230
230
+
recipient of ordinary skill to be able to understand it.
231
231
+
232
232
+
5. Termination
233
233
+
--------------
234
234
+
235
235
+
5.1. The rights granted under this License will terminate automatically
236
236
+
if You fail to comply with any of its terms. However, if You become
237
237
+
compliant, then the rights granted under this License from a particular
238
238
+
Contributor are reinstated (a) provisionally, unless and until such
239
239
+
Contributor explicitly and finally terminates Your grants, and (b) on an
240
240
+
ongoing basis, if such Contributor fails to notify You of the
241
241
+
non-compliance by some reasonable means prior to 60 days after You have
242
242
+
come back into compliance. Moreover, Your grants from a particular
243
243
+
Contributor are reinstated on an ongoing basis if such Contributor
244
244
+
notifies You of the non-compliance by some reasonable means, this is the
245
245
+
first time You have received notice of non-compliance with this License
246
246
+
from such Contributor, and You become compliant prior to 30 days after
247
247
+
Your receipt of the notice.
248
248
+
249
249
+
5.2. If You initiate litigation against any entity by asserting a patent
250
250
+
infringement claim (excluding declaratory judgment actions,
251
251
+
counter-claims, and cross-claims) alleging that a Contributor Version
252
252
+
directly or indirectly infringes any patent, then the rights granted to
253
253
+
You by any and all Contributors for the Covered Software under Section
254
254
+
2.1 of this License shall terminate.
255
255
+
256
256
+
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
257
257
+
end user license agreements (excluding distributors and resellers) which
258
258
+
have been validly granted by You or Your distributors under this License
259
259
+
prior to termination shall survive termination.
260
260
+
261
261
+
************************************************************************
262
262
+
* *
263
263
+
* 6. Disclaimer of Warranty *
264
264
+
* ------------------------- *
265
265
+
* *
266
266
+
* Covered Software is provided under this License on an "as is" *
267
267
+
* basis, without warranty of any kind, either expressed, implied, or *
268
268
+
* statutory, including, without limitation, warranties that the *
269
269
+
* Covered Software is free of defects, merchantable, fit for a *
270
270
+
* particular purpose or non-infringing. The entire risk as to the *
271
271
+
* quality and performance of the Covered Software is with You. *
272
272
+
* Should any Covered Software prove defective in any respect, You *
273
273
+
* (not any Contributor) assume the cost of any necessary servicing, *
274
274
+
* repair, or correction. This disclaimer of warranty constitutes an *
275
275
+
* essential part of this License. No use of any Covered Software is *
276
276
+
* authorized under this License except under this disclaimer. *
277
277
+
* *
278
278
+
************************************************************************
279
279
+
280
280
+
************************************************************************
281
281
+
* *
282
282
+
* 7. Limitation of Liability *
283
283
+
* -------------------------- *
284
284
+
* *
285
285
+
* Under no circumstances and under no legal theory, whether tort *
286
286
+
* (including negligence), contract, or otherwise, shall any *
287
287
+
* Contributor, or anyone who distributes Covered Software as *
288
288
+
* permitted above, be liable to You for any direct, indirect, *
289
289
+
* special, incidental, or consequential damages of any character *
290
290
+
* including, without limitation, damages for lost profits, loss of *
291
291
+
* goodwill, work stoppage, computer failure or malfunction, or any *
292
292
+
* and all other commercial damages or losses, even if such party *
293
293
+
* shall have been informed of the possibility of such damages. This *
294
294
+
* limitation of liability shall not apply to liability for death or *
295
295
+
* personal injury resulting from such party's negligence to the *
296
296
+
* extent applicable law prohibits such limitation. Some *
297
297
+
* jurisdictions do not allow the exclusion or limitation of *
298
298
+
* incidental or consequential damages, so this exclusion and *
299
299
+
* limitation may not apply to You. *
300
300
+
* *
301
301
+
************************************************************************
302
302
+
303
303
+
8. Litigation
304
304
+
-------------
305
305
+
306
306
+
Any litigation relating to this License may be brought only in the
307
307
+
courts of a jurisdiction where the defendant maintains its principal
308
308
+
place of business and such litigation shall be governed by laws of that
309
309
+
jurisdiction, without reference to its conflict-of-law provisions.
310
310
+
Nothing in this Section shall prevent a party's ability to bring
311
311
+
cross-claims or counter-claims.
312
312
+
313
313
+
9. Miscellaneous
314
314
+
----------------
315
315
+
316
316
+
This License represents the complete agreement concerning the subject
317
317
+
matter hereof. If any provision of this License is held to be
318
318
+
unenforceable, such provision shall be reformed only to the extent
319
319
+
necessary to make it enforceable. Any law or regulation which provides
320
320
+
that the language of a contract shall be construed against the drafter
321
321
+
shall not be used to construe this License against a Contributor.
322
322
+
323
323
+
10. Versions of the License
324
324
+
---------------------------
325
325
+
326
326
+
10.1. New Versions
327
327
+
328
328
+
Mozilla Foundation is the license steward. Except as provided in Section
329
329
+
10.3, no one other than the license steward has the right to modify or
330
330
+
publish new versions of this License. Each version will be given a
331
331
+
distinguishing version number.
332
332
+
333
333
+
10.2. Effect of New Versions
334
334
+
335
335
+
You may distribute the Covered Software under the terms of the version
336
336
+
of the License under which You originally received the Covered Software,
337
337
+
or under the terms of any subsequent version published by the license
338
338
+
steward.
339
339
+
340
340
+
10.3. Modified Versions
341
341
+
342
342
+
If you create software not governed by this License, and you want to
343
343
+
create a new license for such software, you may create and use a
344
344
+
modified version of this License if you rename the license and remove
345
345
+
any references to the name of the license steward (except to note that
346
346
+
such modified license differs from this License).
347
347
+
348
348
+
10.4. Distributing Source Code Form that is Incompatible With Secondary
349
349
+
Licenses
350
350
+
351
351
+
If You choose to distribute Source Code Form that is Incompatible With
352
352
+
Secondary Licenses under the terms of this version of the License, the
353
353
+
notice described in Exhibit B of this License must be attached.
354
354
+
355
355
+
Exhibit A - Source Code Form License Notice
356
356
+
-------------------------------------------
357
357
+
358
358
+
This Source Code Form is subject to the terms of the Mozilla Public
359
359
+
License, v. 2.0. If a copy of the MPL was not distributed with this
360
360
+
file, You can obtain one at http://mozilla.org/MPL/2.0/.
361
361
+
362
362
+
If it is not possible or desirable to put the notice in a particular
363
363
+
file, then You may include the notice in a location (such as a LICENSE
364
364
+
file in a relevant directory) where a recipient would be likely to look
365
365
+
for such a notice.
366
366
+
367
367
+
You may add additional accurate notices of copyright ownership.
368
368
+
369
369
+
Exhibit B - "Incompatible With Secondary Licenses" Notice
370
370
+
---------------------------------------------------------
371
371
+
372
372
+
This Source Code Form is "Incompatible With Secondary Licenses", as
373
373
+
defined by the Mozilla Public License, v. 2.0.
···
1
1
+
# fin ⚡
2
2
+
3
3
+
> a neon-electric Jellyfin client for the terminal — powered by `mpv` and Chromecast
4
4
+
5
5
+
`fin` is a Rust TUI + one-shot CLI that talks to your Jellyfin server, searches
6
6
+
your library, manages playlists, and pushes streams to either your local **mpv**
7
7
+
window or any **Chromecast** on your network. Chromecast playback is fully
8
8
+
queued — enqueue, play-next, skip, resume, all from the terminal.
9
9
+
10
10
+
## Features
11
11
+
12
12
+
- **Ratatui-based TUI** with a neon-electric palette (teal / cyan / violet).
13
13
+
- **fzf-style instant search** — results update on every keystroke.
14
14
+
- **Two renderers**, one interface:
15
15
+
- `mpv` (local) — spawned automatically and driven via its JSON IPC socket.
16
16
+
- `chromecast` — device discovery via mDNS, playback through the Default
17
17
+
Media Receiver, with a **local queue** that auto-advances on `FINISHED`.
18
18
+
- **Real queue management** — enqueue, play next, jump between tracks, and
19
19
+
see the whole queue in its own tab. Works identically for both renderers.
20
20
+
- **Playlists** — browse, open, and play the playlists you've saved on the
21
21
+
server.
22
22
+
- **Now Playing bar** with title, subtitle, elapsed / total time, a neon
23
23
+
progress gauge, volume, and the active renderer.
24
24
+
- **CLI shortcuts** for scripting: `fin play "kind of blue"`,
25
25
+
`fin queue --chromecast "Living Room" "wednesday"`, `fin devices`.
26
26
+
- **All settings** are available as **CLI flags _or_ TOML keys** — one
27
27
+
workflow scales from ad-hoc invocation to per-machine config.
28
28
+
- **Pure Rust TLS** (`rustls`) everywhere — no OpenSSL required.
29
29
+
30
30
+
## Install
31
31
+
32
32
+
`fin` requires **`mpv`** on your `$PATH` (it's used as the local renderer and
33
33
+
as a required preflight check even when casting).
34
34
+
35
35
+
```bash
36
36
+
# macOS
37
37
+
brew install mpv
38
38
+
39
39
+
# Debian / Ubuntu
40
40
+
sudo apt install mpv
41
41
+
42
42
+
# Arch
43
43
+
sudo pacman -S mpv
44
44
+
```
45
45
+
46
46
+
Then build fin from source:
47
47
+
48
48
+
```bash
49
49
+
git clone https://github.com/tsirysndr/fin
50
50
+
cd fin
51
51
+
cargo install --path crates/fin
52
52
+
```
53
53
+
54
54
+
### Nix
55
55
+
56
56
+
A flake is provided — mpv is baked into the wrapper, so no extra install
57
57
+
step is needed:
58
58
+
59
59
+
```bash
60
60
+
# One-off run:
61
61
+
nix run github:tsirysndr/fin
62
62
+
63
63
+
# Install into your user profile:
64
64
+
nix profile install github:tsirysndr/fin
65
65
+
66
66
+
# Dev shell (rust toolchain + mpv + clippy + rust-analyzer):
67
67
+
nix develop
68
68
+
```
69
69
+
70
70
+
## Getting started
71
71
+
72
72
+
```bash
73
73
+
# 1. Sign in
74
74
+
fin login https://media.example.com
75
75
+
76
76
+
# 2. Launch the TUI (default sub-command)
77
77
+
fin
78
78
+
79
79
+
# 3. Or drive it entirely from the shell
80
80
+
fin search "daft punk"
81
81
+
fin play "kind of blue"
82
82
+
fin queue "wednesday season 1"
83
83
+
fin devices # list Chromecasts on your LAN
84
84
+
fin play --chromecast "Living Room" "solaris"
85
85
+
```
86
86
+
87
87
+
## Renderer selection
88
88
+
89
89
+
Three ways to choose a renderer — all equivalent:
90
90
+
91
91
+
| Shortcut flag | Long flag | Config key |
92
92
+
|----------------------------------------|---------------------------|--------------------------------|
93
93
+
| `--mpv` | `--renderer mpv` | `renderer = "mpv"` |
94
94
+
| `--chromecast "Living Room"` | `--renderer chromecast` | `renderer = "chromecast"` |
95
95
+
| _(none — falls back to mpv)_ | | |
96
96
+
97
97
+
When you pass `--chromecast NAME`, the renderer is switched to chromecast
98
98
+
automatically and that device is preferred on connect. If the name is not
99
99
+
found on the network, fin picks the first device discovered.
100
100
+
101
101
+
## All settings
102
102
+
103
103
+
Every setting exists as both a CLI flag and a TOML key. Flags win.
104
104
+
105
105
+
| CLI flag | Env var | TOML key | Default |
106
106
+
|-----------------------|---------------------|---------------------------|------------------|
107
107
+
| `--server URL` | `FIN_SERVER` | `servers[].url` | _(none)_ |
108
108
+
| `--server-name NAME` | `FIN_SERVER_NAME` | `current_server` | _(latest login)_ |
109
109
+
| `--token TOKEN` | `FIN_TOKEN` | `servers[].access_token` | _(from login)_ |
110
110
+
| `--user-id ID` | `FIN_USER_ID` | `servers[].user_id` | _(from login)_ |
111
111
+
| `--user-name NAME` | | `servers[].user_name` | _(from login)_ |
112
112
+
| `--device-id ID` | `FIN_DEVICE_ID` | `servers[].device_id` | random UUID |
113
113
+
| `--renderer <mpv/chromecast>` | `FIN_RENDERER` | `renderer` | `mpv` |
114
114
+
| `--mpv` | | `renderer = "mpv"` | |
115
115
+
| `--chromecast [NAME]` | `FIN_CHROMECAST` | `last_chromecast` | |
116
116
+
| `-v`, `-vv` | | _(log level)_ | `warn` |
117
117
+
118
118
+
Find the on-disk config with `fin config --path`; print it with
119
119
+
`fin config --show`.
120
120
+
121
121
+
## Multiple servers
122
122
+
123
123
+
fin authenticates against as many Jellyfin servers as you like and keeps
124
124
+
their tokens side-by-side in one config file:
125
125
+
126
126
+
```bash
127
127
+
fin login https://home.example.com --name home
128
128
+
fin login https://work.example.com --name work
129
129
+
fin login https://mom.dyndns.example --name mom
130
130
+
131
131
+
fin server # list all servers (▍ marks the current one)
132
132
+
fin server switch work # make `work` the active server
133
133
+
fin server rm mom # remove one
134
134
+
fin server rename home casa # rename `home` → `casa`
135
135
+
136
136
+
# One-off — hit `work` without changing the current pointer:
137
137
+
fin --server-name work search "spirited away"
138
138
+
fin --server-name work play "spirited away"
139
139
+
```
140
140
+
141
141
+
Inside the TUI, the Settings screen shows every saved server; **Enter** on
142
142
+
one switches to it. **`t`** anywhere in the TUI cycles to the next server
143
143
+
without leaving the current screen.
144
144
+
145
145
+
## Sub-commands
146
146
+
147
147
+
```
148
148
+
fin # launch the TUI (default)
149
149
+
fin login <url> [--name N] # sign in and save credentials for server `N`
150
150
+
fin logout [--name N] # remove server `N` (defaults to the current one)
151
151
+
fin server # list saved servers
152
152
+
fin server switch <name> # change the active server
153
153
+
fin server rm <name> # remove one
154
154
+
fin server rename <a> <b> # rename
155
155
+
fin search <query> # print matches from the active library
156
156
+
fin play <query> # search + play the top hit
157
157
+
fin queue <query> # search + append to the current queue
158
158
+
fin devices # list Chromecasts on the local network
159
159
+
fin playlists # list playlists
160
160
+
fin playlists --list <id> # dump items of a playlist
161
161
+
fin config --show|--path # inspect config
162
162
+
```
163
163
+
164
164
+
## Keybindings (TUI)
165
165
+
166
166
+
| Key | Action |
167
167
+
|------------------------------|-------------------------------------|
168
168
+
| `Tab` / `Shift+Tab` | next / prev screen |
169
169
+
| `1`…`8` | jump to Home / Search / Music / Videos / Playlists / Queue / Devices / Settings |
170
170
+
| `/` | jump to Search & focus input |
171
171
+
| `↑` `↓` / `k` `j` | move selection |
172
172
+
| `PgUp` / `PgDown` | jump 10 rows |
173
173
+
| `Enter` | play the highlighted item (or connect to the highlighted Chromecast) |
174
174
+
| `a` | enqueue the highlighted item |
175
175
+
| `n` | play the highlighted item **next** |
176
176
+
| `Space` or `p` | pause / resume |
177
177
+
| `s` | stop |
178
178
+
| `<` / `>` or `h` / `l` | previous / next track |
179
179
+
| `+` / `-` | volume up / down |
180
180
+
| `m` | switch to local mpv renderer |
181
181
+
| `t` | cycle to the next saved Jellyfin server |
182
182
+
| `r` | refresh the current screen |
183
183
+
| `Esc` | leave the search input / close open playlist |
184
184
+
| `q` / `Ctrl-C` | quit |
185
185
+
186
186
+
## Chromecast queue
187
187
+
188
188
+
For Chromecasts, `fin` maintains the queue **on the client**, tracks the
189
189
+
device's media session, and loads the next item automatically the moment
190
190
+
the current one reports `IDLE / FINISHED`. That means:
191
191
+
192
192
+
- `a` (queue) and `n` (play-next) do the right thing while something is
193
193
+
already casting.
194
194
+
- Skipping (`>` / `<`) triggers a `load` for the next queue item
195
195
+
immediately — no waiting for the current one to finish.
196
196
+
- Stopping clears the local queue and stops the receiver's media session.
197
197
+
198
198
+
## Streams & transcoding
199
199
+
200
200
+
- Local **mpv** playback uses the original stream (`Static=true`), which
201
201
+
is the fastest path and lets mpv handle any container Jellyfin can
202
202
+
direct-stream.
203
203
+
- **Chromecast** playback defaults to Jellyfin's HLS output (`main.m3u8`),
204
204
+
because the Default Media Receiver's codec matrix is much narrower than
205
205
+
mpv's. This means Jellyfin will transcode when it needs to.
206
206
+
- Force one or the other from the CLI with `--hls` on `play`/`queue`.
207
207
+
208
208
+
## Development
209
209
+
210
210
+
```bash
211
211
+
cargo check --workspace
212
212
+
cargo build --release -p fin
213
213
+
./target/release/fin --help
214
214
+
```
215
215
+
216
216
+
The workspace layout:
217
217
+
218
218
+
```
219
219
+
fin/
220
220
+
├── crates/
221
221
+
│ ├── fin/ # binary — clap CLI + startup
222
222
+
│ ├── fin-config/ # TOML config file & credentials
223
223
+
│ ├── fin-jellyfin/ # Jellyfin HTTP API client
224
224
+
│ ├── fin-player/ # Renderer trait + mpv + Chromecast + queue
225
225
+
│ └── fin-tui/ # Ratatui neon TUI
226
226
+
└── Cargo.toml # workspace + shared deps (rustls only, no openssl)
227
227
+
```
228
228
+
229
229
+
## License
230
230
+
231
231
+
`fin` is released under the [MPL-2.0](LICENSE).
···
1
1
+
[package]
2
2
+
name = "fin-config"
3
3
+
version.workspace = true
4
4
+
edition.workspace = true
5
5
+
authors.workspace = true
6
6
+
license.workspace = true
7
7
+
repository.workspace = true
8
8
+
9
9
+
[dependencies]
10
10
+
anyhow.workspace = true
11
11
+
serde.workspace = true
12
12
+
toml.workspace = true
13
13
+
directories.workspace = true
···
1
1
+
use std::fs;
2
2
+
use std::path::PathBuf;
3
3
+
4
4
+
use anyhow::{anyhow, Context, Result};
5
5
+
use directories::ProjectDirs;
6
6
+
use serde::{Deserialize, Serialize};
7
7
+
8
8
+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9
9
+
pub struct Config {
10
10
+
/// Name of the active server (must exist in `servers`).
11
11
+
#[serde(default)]
12
12
+
pub current_server: Option<String>,
13
13
+
/// All servers the user has authenticated against.
14
14
+
#[serde(default)]
15
15
+
pub servers: Vec<ServerConfig>,
16
16
+
/// Legacy single-server slot. Kept only for one-shot migration; new
17
17
+
/// writes drop it. If both this and `servers` are present, `servers`
18
18
+
/// wins and this is ignored.
19
19
+
#[serde(default, skip_serializing_if = "Option::is_none")]
20
20
+
pub server: Option<LegacyServerConfig>,
21
21
+
#[serde(default)]
22
22
+
pub renderer: RendererPref,
23
23
+
#[serde(default)]
24
24
+
pub last_chromecast: Option<String>,
25
25
+
#[serde(default)]
26
26
+
pub client: ClientInfo,
27
27
+
}
28
28
+
29
29
+
#[derive(Debug, Clone, Serialize, Deserialize)]
30
30
+
pub struct ServerConfig {
31
31
+
/// Short label the user picks (or auto-derived from the URL host).
32
32
+
pub name: String,
33
33
+
pub url: String,
34
34
+
pub user_id: String,
35
35
+
pub user_name: String,
36
36
+
pub access_token: String,
37
37
+
pub device_id: String,
38
38
+
}
39
39
+
40
40
+
/// The old, single-server shape we used before multi-server support landed.
41
41
+
/// Loaded once, then folded into `servers` and never written again.
42
42
+
#[derive(Debug, Clone, Serialize, Deserialize)]
43
43
+
pub struct LegacyServerConfig {
44
44
+
pub url: String,
45
45
+
pub user_id: String,
46
46
+
pub user_name: String,
47
47
+
pub access_token: String,
48
48
+
pub device_id: String,
49
49
+
}
50
50
+
51
51
+
impl From<LegacyServerConfig> for ServerConfig {
52
52
+
fn from(l: LegacyServerConfig) -> Self {
53
53
+
let name = derive_server_name(&l.url);
54
54
+
Self {
55
55
+
name,
56
56
+
url: l.url,
57
57
+
user_id: l.user_id,
58
58
+
user_name: l.user_name,
59
59
+
access_token: l.access_token,
60
60
+
device_id: l.device_id,
61
61
+
}
62
62
+
}
63
63
+
}
64
64
+
65
65
+
/// Derive a short server name from a URL — the host, without the scheme.
66
66
+
///
67
67
+
/// https://media.example.com:8096/path → "media.example.com"
68
68
+
/// http://192.168.1.42 → "192.168.1.42"
69
69
+
pub fn derive_server_name(url: &str) -> String {
70
70
+
let trimmed = url
71
71
+
.trim_start_matches("https://")
72
72
+
.trim_start_matches("http://");
73
73
+
let host: &str = trimmed
74
74
+
.split(|c: char| c == '/' || c == ':')
75
75
+
.next()
76
76
+
.unwrap_or(trimmed);
77
77
+
if host.is_empty() {
78
78
+
"server".to_string()
79
79
+
} else {
80
80
+
host.to_string()
81
81
+
}
82
82
+
}
83
83
+
84
84
+
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
85
85
+
#[serde(rename_all = "lowercase")]
86
86
+
pub enum RendererPref {
87
87
+
#[default]
88
88
+
Mpv,
89
89
+
Chromecast,
90
90
+
}
91
91
+
92
92
+
impl RendererPref {
93
93
+
pub fn label(&self) -> &'static str {
94
94
+
match self {
95
95
+
Self::Mpv => "mpv",
96
96
+
Self::Chromecast => "chromecast",
97
97
+
}
98
98
+
}
99
99
+
}
100
100
+
101
101
+
#[derive(Debug, Clone, Serialize, Deserialize)]
102
102
+
pub struct ClientInfo {
103
103
+
pub name: String,
104
104
+
pub version: String,
105
105
+
}
106
106
+
107
107
+
impl Default for ClientInfo {
108
108
+
fn default() -> Self {
109
109
+
Self {
110
110
+
name: "fin".into(),
111
111
+
version: env!("CARGO_PKG_VERSION").into(),
112
112
+
}
113
113
+
}
114
114
+
}
115
115
+
116
116
+
fn project_dirs() -> Result<ProjectDirs> {
117
117
+
ProjectDirs::from("app", "rocksky", "fin").context("could not determine project dirs")
118
118
+
}
119
119
+
120
120
+
pub fn config_path() -> Result<PathBuf> {
121
121
+
let dirs = project_dirs()?;
122
122
+
Ok(dirs.config_dir().join("config.toml"))
123
123
+
}
124
124
+
125
125
+
pub fn config_dir() -> Result<PathBuf> {
126
126
+
let dirs = project_dirs()?;
127
127
+
Ok(dirs.config_dir().to_path_buf())
128
128
+
}
129
129
+
130
130
+
pub fn cache_dir() -> Result<PathBuf> {
131
131
+
let dirs = project_dirs()?;
132
132
+
Ok(dirs.cache_dir().to_path_buf())
133
133
+
}
134
134
+
135
135
+
impl Config {
136
136
+
pub fn load() -> Result<Self> {
137
137
+
let path = config_path()?;
138
138
+
if !path.exists() {
139
139
+
return Ok(Self::default());
140
140
+
}
141
141
+
let text = fs::read_to_string(&path)
142
142
+
.with_context(|| format!("reading config at {}", path.display()))?;
143
143
+
let mut cfg: Self = toml::from_str(&text).context("parsing config file")?;
144
144
+
cfg.migrate_legacy_server();
145
145
+
Ok(cfg)
146
146
+
}
147
147
+
148
148
+
/// Fold a leftover `[server]` block into the `servers` list. Idempotent.
149
149
+
fn migrate_legacy_server(&mut self) {
150
150
+
if let Some(legacy) = self.server.take() {
151
151
+
let migrated: ServerConfig = legacy.into();
152
152
+
let already = self.servers.iter().any(|s| s.url == migrated.url);
153
153
+
if !already {
154
154
+
let name = migrated.name.clone();
155
155
+
self.servers.push(migrated);
156
156
+
if self.current_server.is_none() {
157
157
+
self.current_server = Some(name);
158
158
+
}
159
159
+
}
160
160
+
}
161
161
+
if self.current_server.is_none() {
162
162
+
self.current_server = self.servers.first().map(|s| s.name.clone());
163
163
+
}
164
164
+
}
165
165
+
166
166
+
pub fn save(&self) -> Result<()> {
167
167
+
let path = config_path()?;
168
168
+
if let Some(parent) = path.parent() {
169
169
+
fs::create_dir_all(parent).context("creating config dir")?;
170
170
+
}
171
171
+
let text = toml::to_string_pretty(self).context("serializing config")?;
172
172
+
fs::write(&path, text).with_context(|| format!("writing config to {}", path.display()))?;
173
173
+
Ok(())
174
174
+
}
175
175
+
176
176
+
pub fn current(&self) -> Option<&ServerConfig> {
177
177
+
let name = self.current_server.as_ref()?;
178
178
+
self.servers.iter().find(|s| &s.name == name)
179
179
+
}
180
180
+
181
181
+
pub fn require_current(&self) -> Result<&ServerConfig> {
182
182
+
self.current()
183
183
+
.context("no active server — run `fin login <url>` or `fin server switch <name>`")
184
184
+
}
185
185
+
186
186
+
pub fn find_server(&self, name: &str) -> Option<&ServerConfig> {
187
187
+
self.servers.iter().find(|s| s.name == name)
188
188
+
}
189
189
+
190
190
+
/// Add or update a server (upsert by name) and mark it as the current one.
191
191
+
pub fn add_or_update_server(&mut self, server: ServerConfig) {
192
192
+
if let Some(existing) = self.servers.iter_mut().find(|s| s.name == server.name) {
193
193
+
*existing = server.clone();
194
194
+
} else {
195
195
+
self.servers.push(server.clone());
196
196
+
}
197
197
+
self.current_server = Some(server.name);
198
198
+
}
199
199
+
200
200
+
/// Switch the active server by name.
201
201
+
pub fn switch_to(&mut self, name: &str) -> Result<()> {
202
202
+
if !self.servers.iter().any(|s| s.name == name) {
203
203
+
return Err(anyhow!(
204
204
+
"no server named `{}` — try `fin server` to list them",
205
205
+
name
206
206
+
));
207
207
+
}
208
208
+
self.current_server = Some(name.to_string());
209
209
+
Ok(())
210
210
+
}
211
211
+
212
212
+
/// Remove a server by name. Falls back to the first remaining server as
213
213
+
/// the new current one, or clears `current_server` if none remain.
214
214
+
pub fn remove_server(&mut self, name: &str) -> Result<()> {
215
215
+
let before = self.servers.len();
216
216
+
self.servers.retain(|s| s.name != name);
217
217
+
if self.servers.len() == before {
218
218
+
return Err(anyhow!("no server named `{}`", name));
219
219
+
}
220
220
+
if self.current_server.as_deref() == Some(name) {
221
221
+
self.current_server = self.servers.first().map(|s| s.name.clone());
222
222
+
}
223
223
+
Ok(())
224
224
+
}
225
225
+
226
226
+
pub fn cycle_next(&mut self) -> Option<&ServerConfig> {
227
227
+
if self.servers.is_empty() {
228
228
+
return None;
229
229
+
}
230
230
+
let cur = self
231
231
+
.current_server
232
232
+
.as_deref()
233
233
+
.and_then(|n| self.servers.iter().position(|s| s.name == n))
234
234
+
.unwrap_or(0);
235
235
+
let next = (cur + 1) % self.servers.len();
236
236
+
self.current_server = Some(self.servers[next].name.clone());
237
237
+
self.servers.get(next)
238
238
+
}
239
239
+
}
···
1
1
+
[package]
2
2
+
name = "fin-jellyfin"
3
3
+
version.workspace = true
4
4
+
edition.workspace = true
5
5
+
authors.workspace = true
6
6
+
license.workspace = true
7
7
+
repository.workspace = true
8
8
+
9
9
+
[dependencies]
10
10
+
anyhow.workspace = true
11
11
+
thiserror.workspace = true
12
12
+
serde.workspace = true
13
13
+
serde_json.workspace = true
14
14
+
reqwest.workspace = true
15
15
+
url.workspace = true
16
16
+
uuid.workspace = true
17
17
+
chrono.workspace = true
18
18
+
tracing.workspace = true
19
19
+
tokio.workspace = true
20
20
+
percent-encoding.workspace = true
21
21
+
whoami.workspace = true
22
22
+
fin-config.workspace = true
···
1
1
+
use anyhow::{Context, Result};
2
2
+
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
3
3
+
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
4
4
+
use serde::Serialize;
5
5
+
use serde_json::json;
6
6
+
use tracing::debug;
7
7
+
use uuid::Uuid;
8
8
+
9
9
+
use crate::models::{AuthResult, BaseItem, SearchHintResult, SearchResult, UserViewsResult};
10
10
+
11
11
+
const CLIENT_NAME: &str = "fin";
12
12
+
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
13
13
+
14
14
+
#[derive(Debug, Clone, Copy)]
15
15
+
pub enum StreamFormat {
16
16
+
/// Direct/original stream if possible.
17
17
+
Direct,
18
18
+
/// HLS transcoded (useful for Chromecast).
19
19
+
Hls,
20
20
+
}
21
21
+
22
22
+
#[derive(Debug, Clone)]
23
23
+
pub struct JellyfinClient {
24
24
+
base_url: String,
25
25
+
device_id: String,
26
26
+
device_name: String,
27
27
+
access_token: Option<String>,
28
28
+
user_id: Option<String>,
29
29
+
http: reqwest::Client,
30
30
+
}
31
31
+
32
32
+
impl JellyfinClient {
33
33
+
pub fn new(base_url: impl Into<String>) -> Result<Self> {
34
34
+
let base_url = base_url.into().trim_end_matches('/').to_string();
35
35
+
let http = reqwest::Client::builder()
36
36
+
.user_agent(format!("{}/{}", CLIENT_NAME, CLIENT_VERSION))
37
37
+
.build()?;
38
38
+
let device_name = whoami::fallible::hostname().unwrap_or_else(|_| "fin-cli".to_string());
39
39
+
Ok(Self {
40
40
+
base_url,
41
41
+
device_id: Uuid::new_v4().to_string(),
42
42
+
device_name,
43
43
+
access_token: None,
44
44
+
user_id: None,
45
45
+
http,
46
46
+
})
47
47
+
}
48
48
+
49
49
+
pub fn with_credentials(
50
50
+
base_url: impl Into<String>,
51
51
+
device_id: impl Into<String>,
52
52
+
user_id: impl Into<String>,
53
53
+
access_token: impl Into<String>,
54
54
+
) -> Result<Self> {
55
55
+
let mut c = Self::new(base_url)?;
56
56
+
c.device_id = device_id.into();
57
57
+
c.user_id = Some(user_id.into());
58
58
+
c.access_token = Some(access_token.into());
59
59
+
Ok(c)
60
60
+
}
61
61
+
62
62
+
pub fn base_url(&self) -> &str {
63
63
+
&self.base_url
64
64
+
}
65
65
+
66
66
+
pub fn device_id(&self) -> &str {
67
67
+
&self.device_id
68
68
+
}
69
69
+
70
70
+
pub fn access_token(&self) -> Option<&str> {
71
71
+
self.access_token.as_deref()
72
72
+
}
73
73
+
74
74
+
pub fn user_id(&self) -> Option<&str> {
75
75
+
self.user_id.as_deref()
76
76
+
}
77
77
+
78
78
+
fn auth_header(&self) -> String {
79
79
+
let token = self
80
80
+
.access_token
81
81
+
.as_deref()
82
82
+
.map(|t| format!(", Token=\"{}\"", t))
83
83
+
.unwrap_or_default();
84
84
+
format!(
85
85
+
"MediaBrowser Client=\"{}\", Device=\"{}\", DeviceId=\"{}\", Version=\"{}\"{}",
86
86
+
CLIENT_NAME, self.device_name, self.device_id, CLIENT_VERSION, token
87
87
+
)
88
88
+
}
89
89
+
90
90
+
fn headers(&self) -> Result<HeaderMap> {
91
91
+
let mut h = HeaderMap::new();
92
92
+
h.insert(ACCEPT, HeaderValue::from_static("application/json"));
93
93
+
h.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
94
94
+
h.insert(
95
95
+
AUTHORIZATION,
96
96
+
HeaderValue::from_str(&self.auth_header()).context("invalid auth header")?,
97
97
+
);
98
98
+
Ok(h)
99
99
+
}
100
100
+
101
101
+
fn url(&self, path: &str) -> String {
102
102
+
format!("{}{}", self.base_url, path)
103
103
+
}
104
104
+
105
105
+
/// Authenticate by username/password and store the access token on the client.
106
106
+
pub async fn login(&mut self, username: &str, password: &str) -> Result<AuthResult> {
107
107
+
let url = self.url("/Users/AuthenticateByName");
108
108
+
let body = json!({
109
109
+
"Username": username,
110
110
+
"Pw": password,
111
111
+
});
112
112
+
debug!(?url, "authenticating");
113
113
+
let resp = self
114
114
+
.http
115
115
+
.post(&url)
116
116
+
.headers(self.headers()?)
117
117
+
.json(&body)
118
118
+
.send()
119
119
+
.await?;
120
120
+
let status = resp.status();
121
121
+
if !status.is_success() {
122
122
+
let text = resp.text().await.unwrap_or_default();
123
123
+
anyhow::bail!("login failed ({}): {}", status, text);
124
124
+
}
125
125
+
let auth: AuthResult = resp.json().await.context("parsing auth response")?;
126
126
+
self.access_token = Some(auth.access_token.clone());
127
127
+
self.user_id = Some(auth.user.id.clone());
128
128
+
Ok(auth)
129
129
+
}
130
130
+
131
131
+
/// Fetch the current user's library views (Movies, TV Shows, Music, …).
132
132
+
pub async fn views(&self) -> Result<Vec<crate::models::UserView>> {
133
133
+
let user_id = self.user_id.as_ref().context("not authenticated")?;
134
134
+
let url = self.url(&format!("/Users/{}/Views", user_id));
135
135
+
let resp = self.http.get(&url).headers(self.headers()?).send().await?;
136
136
+
let res: UserViewsResult = resp.error_for_status()?.json().await?;
137
137
+
Ok(res.items)
138
138
+
}
139
139
+
140
140
+
/// Fetch children of a parent (library, folder, album, playlist, …).
141
141
+
pub async fn items(
142
142
+
&self,
143
143
+
parent_id: Option<&str>,
144
144
+
include_types: &[&str],
145
145
+
recursive: bool,
146
146
+
sort_by: Option<&str>,
147
147
+
limit: Option<u32>,
148
148
+
) -> Result<Vec<BaseItem>> {
149
149
+
let user_id = self.user_id.as_ref().context("not authenticated")?;
150
150
+
let mut q: Vec<(String, String)> = vec![
151
151
+
(
152
152
+
"Fields".into(),
153
153
+
"PrimaryImageAspectRatio,ProductionYear,Overview".into(),
154
154
+
),
155
155
+
("Recursive".into(), recursive.to_string()),
156
156
+
];
157
157
+
if let Some(p) = parent_id {
158
158
+
q.push(("ParentId".into(), p.into()));
159
159
+
}
160
160
+
if !include_types.is_empty() {
161
161
+
q.push(("IncludeItemTypes".into(), include_types.join(",")));
162
162
+
}
163
163
+
if let Some(s) = sort_by {
164
164
+
q.push(("SortBy".into(), s.into()));
165
165
+
q.push(("SortOrder".into(), "Ascending".into()));
166
166
+
}
167
167
+
if let Some(l) = limit {
168
168
+
q.push(("Limit".into(), l.to_string()));
169
169
+
}
170
170
+
let url = self.url(&format!("/Users/{}/Items", user_id));
171
171
+
let resp = self
172
172
+
.http
173
173
+
.get(&url)
174
174
+
.headers(self.headers()?)
175
175
+
.query(&q)
176
176
+
.send()
177
177
+
.await?;
178
178
+
let res: SearchResult = resp.error_for_status()?.json().await?;
179
179
+
Ok(res.items)
180
180
+
}
181
181
+
182
182
+
/// Recent / resume items on the home screen.
183
183
+
pub async fn resume(&self, limit: u32) -> Result<Vec<BaseItem>> {
184
184
+
let user_id = self.user_id.as_ref().context("not authenticated")?;
185
185
+
let url = self.url(&format!("/Users/{}/Items/Resume", user_id));
186
186
+
let resp = self
187
187
+
.http
188
188
+
.get(&url)
189
189
+
.headers(self.headers()?)
190
190
+
.query(&[("Limit", limit.to_string())])
191
191
+
.send()
192
192
+
.await?;
193
193
+
let res: SearchResult = resp.error_for_status()?.json().await?;
194
194
+
Ok(res.items)
195
195
+
}
196
196
+
197
197
+
pub async fn latest(&self, parent_id: Option<&str>, limit: u32) -> Result<Vec<BaseItem>> {
198
198
+
let user_id = self.user_id.as_ref().context("not authenticated")?;
199
199
+
let mut q: Vec<(String, String)> = vec![
200
200
+
("Limit".into(), limit.to_string()),
201
201
+
("Fields".into(), "ProductionYear".into()),
202
202
+
];
203
203
+
if let Some(p) = parent_id {
204
204
+
q.push(("ParentId".into(), p.into()));
205
205
+
}
206
206
+
let url = self.url(&format!("/Users/{}/Items/Latest", user_id));
207
207
+
let resp = self
208
208
+
.http
209
209
+
.get(&url)
210
210
+
.headers(self.headers()?)
211
211
+
.query(&q)
212
212
+
.send()
213
213
+
.await?;
214
214
+
let items: Vec<BaseItem> = resp.error_for_status()?.json().await?;
215
215
+
Ok(items)
216
216
+
}
217
217
+
218
218
+
/// Search hints — the fast fuzzy search endpoint Jellyfin's web client uses.
219
219
+
pub async fn search(
220
220
+
&self,
221
221
+
term: &str,
222
222
+
include_types: &[&str],
223
223
+
limit: u32,
224
224
+
) -> Result<Vec<BaseItem>> {
225
225
+
let user_id = self.user_id.as_ref().context("not authenticated")?;
226
226
+
let mut q: Vec<(String, String)> = vec![
227
227
+
("SearchTerm".into(), term.into()),
228
228
+
("UserId".into(), user_id.clone()),
229
229
+
("Limit".into(), limit.to_string()),
230
230
+
];
231
231
+
if !include_types.is_empty() {
232
232
+
q.push(("IncludeItemTypes".into(), include_types.join(",")));
233
233
+
}
234
234
+
let url = self.url("/Search/Hints");
235
235
+
let resp = self
236
236
+
.http
237
237
+
.get(&url)
238
238
+
.headers(self.headers()?)
239
239
+
.query(&q)
240
240
+
.send()
241
241
+
.await?;
242
242
+
let res: SearchHintResult = resp.error_for_status()?.json().await?;
243
243
+
Ok(res
244
244
+
.search_hints
245
245
+
.into_iter()
246
246
+
.map(|h| h.into_base_item())
247
247
+
.collect())
248
248
+
}
249
249
+
250
250
+
/// All playlists visible to the current user.
251
251
+
pub async fn playlists(&self) -> Result<Vec<BaseItem>> {
252
252
+
self.items(None, &["Playlist"], true, Some("SortName"), None)
253
253
+
.await
254
254
+
}
255
255
+
256
256
+
pub async fn playlist_items(&self, playlist_id: &str) -> Result<Vec<BaseItem>> {
257
257
+
let user_id = self.user_id.as_ref().context("not authenticated")?;
258
258
+
let url = self.url(&format!("/Playlists/{}/Items", playlist_id));
259
259
+
let resp = self
260
260
+
.http
261
261
+
.get(&url)
262
262
+
.headers(self.headers()?)
263
263
+
.query(&[("UserId", user_id.as_str())])
264
264
+
.send()
265
265
+
.await?;
266
266
+
let res: SearchResult = resp.error_for_status()?.json().await?;
267
267
+
Ok(res.items)
268
268
+
}
269
269
+
270
270
+
pub async fn create_playlist(
271
271
+
&self,
272
272
+
name: &str,
273
273
+
media_type: &str,
274
274
+
item_ids: &[String],
275
275
+
) -> Result<String> {
276
276
+
let user_id = self.user_id.as_ref().context("not authenticated")?;
277
277
+
let url = self.url("/Playlists");
278
278
+
let body = json!({
279
279
+
"Name": name,
280
280
+
"Ids": item_ids,
281
281
+
"UserId": user_id,
282
282
+
"MediaType": media_type,
283
283
+
});
284
284
+
let resp = self
285
285
+
.http
286
286
+
.post(&url)
287
287
+
.headers(self.headers()?)
288
288
+
.json(&body)
289
289
+
.send()
290
290
+
.await?;
291
291
+
#[derive(serde::Deserialize)]
292
292
+
#[serde(rename_all = "PascalCase")]
293
293
+
struct R {
294
294
+
id: String,
295
295
+
}
296
296
+
let r: R = resp.error_for_status()?.json().await?;
297
297
+
Ok(r.id)
298
298
+
}
299
299
+
300
300
+
pub async fn add_to_playlist(&self, playlist_id: &str, item_ids: &[String]) -> Result<()> {
301
301
+
let user_id = self.user_id.as_ref().context("not authenticated")?;
302
302
+
let url = self.url(&format!("/Playlists/{}/Items", playlist_id));
303
303
+
self.http
304
304
+
.post(&url)
305
305
+
.headers(self.headers()?)
306
306
+
.query(&[("Ids", item_ids.join(",")), ("UserId", user_id.to_string())])
307
307
+
.send()
308
308
+
.await?
309
309
+
.error_for_status()?;
310
310
+
Ok(())
311
311
+
}
312
312
+
313
313
+
pub async fn remove_from_playlist(
314
314
+
&self,
315
315
+
playlist_id: &str,
316
316
+
entry_ids: &[String],
317
317
+
) -> Result<()> {
318
318
+
let url = self.url(&format!("/Playlists/{}/Items", playlist_id));
319
319
+
self.http
320
320
+
.delete(&url)
321
321
+
.headers(self.headers()?)
322
322
+
.query(&[("EntryIds", entry_ids.join(","))])
323
323
+
.send()
324
324
+
.await?
325
325
+
.error_for_status()?;
326
326
+
Ok(())
327
327
+
}
328
328
+
329
329
+
/// Build a direct-playback stream URL. `format` chooses between original stream and HLS.
330
330
+
pub fn stream_url(&self, item: &BaseItem, format: StreamFormat) -> Result<String> {
331
331
+
let token = self.access_token.as_deref().context("no access token")?;
332
332
+
let kind = item.kind();
333
333
+
let is_audio = kind.is_audio() || item.media_type.as_deref() == Some("Audio");
334
334
+
let container = if is_audio { "mp3" } else { "mp4" };
335
335
+
let path = match (format, is_audio) {
336
336
+
(StreamFormat::Hls, true) => format!("/Audio/{}/main.m3u8", item.id),
337
337
+
(StreamFormat::Hls, false) => format!("/Videos/{}/main.m3u8", item.id),
338
338
+
(StreamFormat::Direct, true) => {
339
339
+
format!("/Audio/{}/stream.{}", item.id, container)
340
340
+
}
341
341
+
(StreamFormat::Direct, false) => {
342
342
+
format!("/Videos/{}/stream.{}", item.id, container)
343
343
+
}
344
344
+
};
345
345
+
let mut params: Vec<(&str, String)> = vec![
346
346
+
("api_key", token.to_string()),
347
347
+
("DeviceId", self.device_id.clone()),
348
348
+
("Static", "true".into()),
349
349
+
];
350
350
+
if matches!(format, StreamFormat::Hls) {
351
351
+
params.push(("PlaySessionId", Uuid::new_v4().to_string()));
352
352
+
}
353
353
+
let qs = params
354
354
+
.into_iter()
355
355
+
.map(|(k, v)| {
356
356
+
format!(
357
357
+
"{}={}",
358
358
+
k,
359
359
+
utf8_percent_encode(&v, NON_ALPHANUMERIC).to_string()
360
360
+
)
361
361
+
})
362
362
+
.collect::<Vec<_>>()
363
363
+
.join("&");
364
364
+
Ok(format!("{}{}?{}", self.base_url, path, qs))
365
365
+
}
366
366
+
367
367
+
/// Primary image URL for an item, suitable for tile previews.
368
368
+
pub fn image_url(&self, item_id: &str, tag: &str, width: u32) -> String {
369
369
+
format!(
370
370
+
"{}/Items/{}/Images/Primary?tag={}&fillWidth={}&format=jpg",
371
371
+
self.base_url, item_id, tag, width
372
372
+
)
373
373
+
}
374
374
+
375
375
+
pub async fn report_started(&self, item: &BaseItem, session_id: &str) -> Result<()> {
376
376
+
self.report(
377
377
+
"/Sessions/Playing",
378
378
+
json!({
379
379
+
"ItemId": item.id,
380
380
+
"PlaySessionId": session_id,
381
381
+
"CanSeek": true,
382
382
+
}),
383
383
+
)
384
384
+
.await
385
385
+
}
386
386
+
387
387
+
pub async fn report_progress(
388
388
+
&self,
389
389
+
item: &BaseItem,
390
390
+
session_id: &str,
391
391
+
position_ticks: i64,
392
392
+
paused: bool,
393
393
+
) -> Result<()> {
394
394
+
self.report(
395
395
+
"/Sessions/Playing/Progress",
396
396
+
json!({
397
397
+
"ItemId": item.id,
398
398
+
"PlaySessionId": session_id,
399
399
+
"PositionTicks": position_ticks,
400
400
+
"IsPaused": paused,
401
401
+
"CanSeek": true,
402
402
+
"EventName": "TimeUpdate",
403
403
+
}),
404
404
+
)
405
405
+
.await
406
406
+
}
407
407
+
408
408
+
pub async fn report_stopped(
409
409
+
&self,
410
410
+
item: &BaseItem,
411
411
+
session_id: &str,
412
412
+
position_ticks: i64,
413
413
+
) -> Result<()> {
414
414
+
self.report(
415
415
+
"/Sessions/Playing/Stopped",
416
416
+
json!({
417
417
+
"ItemId": item.id,
418
418
+
"PlaySessionId": session_id,
419
419
+
"PositionTicks": position_ticks,
420
420
+
}),
421
421
+
)
422
422
+
.await
423
423
+
}
424
424
+
425
425
+
async fn report<T: Serialize>(&self, path: &str, body: T) -> Result<()> {
426
426
+
let url = self.url(path);
427
427
+
self.http
428
428
+
.post(&url)
429
429
+
.headers(self.headers()?)
430
430
+
.json(&body)
431
431
+
.send()
432
432
+
.await?
433
433
+
.error_for_status()?;
434
434
+
Ok(())
435
435
+
}
436
436
+
}
···
1
1
+
pub mod client;
2
2
+
pub mod models;
3
3
+
4
4
+
pub use client::{JellyfinClient, StreamFormat};
5
5
+
pub use models::{
6
6
+
AuthResult, BaseItem, ItemKind, Playlist, PlaylistItem, SearchHint, SearchResult, UserView,
7
7
+
};
···
1
1
+
use serde::{Deserialize, Serialize};
2
2
+
3
3
+
#[derive(Debug, Clone, Serialize, Deserialize)]
4
4
+
#[serde(rename_all = "PascalCase")]
5
5
+
pub struct AuthResult {
6
6
+
pub access_token: String,
7
7
+
pub server_id: String,
8
8
+
pub user: AuthUser,
9
9
+
}
10
10
+
11
11
+
#[derive(Debug, Clone, Serialize, Deserialize)]
12
12
+
#[serde(rename_all = "PascalCase")]
13
13
+
pub struct AuthUser {
14
14
+
pub id: String,
15
15
+
pub name: String,
16
16
+
}
17
17
+
18
18
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19
19
+
pub enum ItemKind {
20
20
+
Audio,
21
21
+
MusicAlbum,
22
22
+
MusicArtist,
23
23
+
Playlist,
24
24
+
Movie,
25
25
+
Series,
26
26
+
Season,
27
27
+
Episode,
28
28
+
Video,
29
29
+
Folder,
30
30
+
Other,
31
31
+
}
32
32
+
33
33
+
impl ItemKind {
34
34
+
pub fn parse(s: &str) -> Self {
35
35
+
match s {
36
36
+
"Audio" => Self::Audio,
37
37
+
"MusicAlbum" => Self::MusicAlbum,
38
38
+
"MusicArtist" => Self::MusicArtist,
39
39
+
"Playlist" => Self::Playlist,
40
40
+
"Movie" => Self::Movie,
41
41
+
"Series" => Self::Series,
42
42
+
"Season" => Self::Season,
43
43
+
"Episode" => Self::Episode,
44
44
+
"Video" => Self::Video,
45
45
+
"Folder" | "CollectionFolder" => Self::Folder,
46
46
+
_ => Self::Other,
47
47
+
}
48
48
+
}
49
49
+
50
50
+
pub fn is_audio(&self) -> bool {
51
51
+
matches!(self, Self::Audio | Self::MusicAlbum | Self::MusicArtist)
52
52
+
}
53
53
+
54
54
+
pub fn is_video(&self) -> bool {
55
55
+
matches!(
56
56
+
self,
57
57
+
Self::Movie | Self::Series | Self::Season | Self::Episode | Self::Video
58
58
+
)
59
59
+
}
60
60
+
61
61
+
pub fn is_playable(&self) -> bool {
62
62
+
matches!(
63
63
+
self,
64
64
+
Self::Audio | Self::Movie | Self::Episode | Self::Video
65
65
+
)
66
66
+
}
67
67
+
68
68
+
pub fn icon(&self) -> &'static str {
69
69
+
match self {
70
70
+
Self::Audio => "♪",
71
71
+
Self::MusicAlbum => "◈",
72
72
+
Self::MusicArtist => "◊",
73
73
+
Self::Playlist => "▤",
74
74
+
Self::Movie => "▶",
75
75
+
Self::Series => "▤",
76
76
+
Self::Season => "◱",
77
77
+
Self::Episode => "▶",
78
78
+
Self::Video => "▶",
79
79
+
Self::Folder => "▸",
80
80
+
Self::Other => "•",
81
81
+
}
82
82
+
}
83
83
+
}
84
84
+
85
85
+
#[derive(Debug, Clone, Serialize, Deserialize)]
86
86
+
#[serde(rename_all = "PascalCase")]
87
87
+
pub struct BaseItem {
88
88
+
pub id: String,
89
89
+
pub name: String,
90
90
+
#[serde(rename = "Type")]
91
91
+
pub type_: String,
92
92
+
#[serde(default)]
93
93
+
pub album: Option<String>,
94
94
+
#[serde(default)]
95
95
+
pub album_id: Option<String>,
96
96
+
#[serde(default)]
97
97
+
pub album_artist: Option<String>,
98
98
+
#[serde(default)]
99
99
+
pub artists: Option<Vec<String>>,
100
100
+
#[serde(default)]
101
101
+
pub series_name: Option<String>,
102
102
+
#[serde(default)]
103
103
+
pub production_year: Option<i32>,
104
104
+
#[serde(default)]
105
105
+
pub run_time_ticks: Option<i64>,
106
106
+
#[serde(default)]
107
107
+
pub media_type: Option<String>,
108
108
+
#[serde(default)]
109
109
+
pub index_number: Option<i32>,
110
110
+
#[serde(default)]
111
111
+
pub parent_index_number: Option<i32>,
112
112
+
#[serde(default)]
113
113
+
pub image_tags: Option<serde_json::Value>,
114
114
+
#[serde(default)]
115
115
+
pub is_folder: Option<bool>,
116
116
+
#[serde(default)]
117
117
+
pub overview: Option<String>,
118
118
+
}
119
119
+
120
120
+
impl BaseItem {
121
121
+
pub fn kind(&self) -> ItemKind {
122
122
+
ItemKind::parse(&self.type_)
123
123
+
}
124
124
+
125
125
+
pub fn duration_secs(&self) -> Option<u64> {
126
126
+
self.run_time_ticks.map(|t| (t / 10_000_000) as u64)
127
127
+
}
128
128
+
129
129
+
pub fn subtitle(&self) -> String {
130
130
+
match self.kind() {
131
131
+
ItemKind::Audio => {
132
132
+
let artists = self
133
133
+
.artists
134
134
+
.as_ref()
135
135
+
.and_then(|a| {
136
136
+
if a.is_empty() {
137
137
+
None
138
138
+
} else {
139
139
+
Some(a.join(", "))
140
140
+
}
141
141
+
})
142
142
+
.or_else(|| self.album_artist.clone())
143
143
+
.unwrap_or_default();
144
144
+
let album = self.album.clone().unwrap_or_default();
145
145
+
match (artists.is_empty(), album.is_empty()) {
146
146
+
(true, true) => String::new(),
147
147
+
(false, true) => artists,
148
148
+
(true, false) => album,
149
149
+
(false, false) => format!("{} — {}", artists, album),
150
150
+
}
151
151
+
}
152
152
+
ItemKind::MusicAlbum => self
153
153
+
.album_artist
154
154
+
.clone()
155
155
+
.or_else(|| self.artists.as_ref().map(|a| a.join(", ")))
156
156
+
.unwrap_or_default(),
157
157
+
ItemKind::MusicArtist => String::new(),
158
158
+
ItemKind::Movie => self
159
159
+
.production_year
160
160
+
.map(|y| y.to_string())
161
161
+
.unwrap_or_default(),
162
162
+
ItemKind::Episode => match (self.parent_index_number, self.index_number) {
163
163
+
(Some(s), Some(e)) => format!(
164
164
+
"S{:02}E{:02} — {}",
165
165
+
s,
166
166
+
e,
167
167
+
self.series_name.clone().unwrap_or_default()
168
168
+
),
169
169
+
_ => self.series_name.clone().unwrap_or_default(),
170
170
+
},
171
171
+
ItemKind::Series => self
172
172
+
.production_year
173
173
+
.map(|y| y.to_string())
174
174
+
.unwrap_or_default(),
175
175
+
_ => String::new(),
176
176
+
}
177
177
+
}
178
178
+
}
179
179
+
180
180
+
#[derive(Debug, Clone, Serialize, Deserialize)]
181
181
+
#[serde(rename_all = "PascalCase")]
182
182
+
pub struct SearchResult {
183
183
+
#[serde(default)]
184
184
+
pub items: Vec<BaseItem>,
185
185
+
#[serde(default)]
186
186
+
pub total_record_count: i64,
187
187
+
}
188
188
+
189
189
+
#[derive(Debug, Clone, Serialize, Deserialize)]
190
190
+
#[serde(rename_all = "PascalCase")]
191
191
+
pub struct SearchHintResult {
192
192
+
#[serde(default)]
193
193
+
pub search_hints: Vec<SearchHint>,
194
194
+
#[serde(default)]
195
195
+
pub total_record_count: i64,
196
196
+
}
197
197
+
198
198
+
#[derive(Debug, Clone, Serialize, Deserialize)]
199
199
+
#[serde(rename_all = "PascalCase")]
200
200
+
pub struct SearchHint {
201
201
+
#[serde(default, alias = "ItemId")]
202
202
+
pub id: String,
203
203
+
pub name: String,
204
204
+
#[serde(rename = "Type", default)]
205
205
+
pub type_: String,
206
206
+
#[serde(default)]
207
207
+
pub album: Option<String>,
208
208
+
#[serde(default)]
209
209
+
pub album_artist: Option<String>,
210
210
+
#[serde(default)]
211
211
+
pub artists: Option<Vec<String>>,
212
212
+
#[serde(default)]
213
213
+
pub production_year: Option<i32>,
214
214
+
#[serde(default)]
215
215
+
pub run_time_ticks: Option<i64>,
216
216
+
#[serde(default)]
217
217
+
pub media_type: Option<String>,
218
218
+
#[serde(default)]
219
219
+
pub series_name: Option<String>,
220
220
+
}
221
221
+
222
222
+
impl SearchHint {
223
223
+
pub fn into_base_item(self) -> BaseItem {
224
224
+
BaseItem {
225
225
+
id: self.id,
226
226
+
name: self.name,
227
227
+
type_: self.type_,
228
228
+
album: self.album,
229
229
+
album_id: None,
230
230
+
album_artist: self.album_artist,
231
231
+
artists: self.artists,
232
232
+
series_name: self.series_name,
233
233
+
production_year: self.production_year,
234
234
+
run_time_ticks: self.run_time_ticks,
235
235
+
media_type: self.media_type,
236
236
+
index_number: None,
237
237
+
parent_index_number: None,
238
238
+
image_tags: None,
239
239
+
is_folder: None,
240
240
+
overview: None,
241
241
+
}
242
242
+
}
243
243
+
}
244
244
+
245
245
+
#[derive(Debug, Clone, Serialize, Deserialize)]
246
246
+
#[serde(rename_all = "PascalCase")]
247
247
+
pub struct UserView {
248
248
+
pub id: String,
249
249
+
pub name: String,
250
250
+
#[serde(default)]
251
251
+
pub collection_type: Option<String>,
252
252
+
}
253
253
+
254
254
+
#[derive(Debug, Clone, Serialize, Deserialize)]
255
255
+
#[serde(rename_all = "PascalCase")]
256
256
+
pub struct UserViewsResult {
257
257
+
#[serde(default)]
258
258
+
pub items: Vec<UserView>,
259
259
+
}
260
260
+
261
261
+
pub type Playlist = BaseItem;
262
262
+
pub type PlaylistItem = BaseItem;
···
1
1
+
[package]
2
2
+
name = "fin-player"
3
3
+
version.workspace = true
4
4
+
edition.workspace = true
5
5
+
authors.workspace = true
6
6
+
license.workspace = true
7
7
+
repository.workspace = true
8
8
+
9
9
+
[dependencies]
10
10
+
anyhow.workspace = true
11
11
+
thiserror.workspace = true
12
12
+
serde.workspace = true
13
13
+
serde_json.workspace = true
14
14
+
tokio.workspace = true
15
15
+
async-trait.workspace = true
16
16
+
tracing.workspace = true
17
17
+
uuid.workspace = true
18
18
+
parking_lot.workspace = true
19
19
+
futures.workspace = true
20
20
+
mdns-sd.workspace = true
21
21
+
rust_cast.workspace = true
22
22
+
fin-jellyfin.workspace = true
···
1
1
+
use std::sync::mpsc as std_mpsc;
2
2
+
use std::sync::Arc;
3
3
+
use std::thread;
4
4
+
use std::time::{Duration, Instant};
5
5
+
6
6
+
use anyhow::{anyhow, Context, Result};
7
7
+
use async_trait::async_trait;
8
8
+
use parking_lot::Mutex;
9
9
+
use tokio::sync::oneshot;
10
10
+
use tracing::error;
11
11
+
12
12
+
use rust_cast::channels::media::{
13
13
+
IdleReason, Image, Media, Metadata, MovieMediaMetadata, MusicTrackMediaMetadata, PlayerState,
14
14
+
StreamType,
15
15
+
};
16
16
+
use rust_cast::channels::receiver::CastDeviceApp;
17
17
+
use rust_cast::CastDevice as RcDevice;
18
18
+
19
19
+
use crate::discovery::CastDevice;
20
20
+
use crate::queue::{PlaybackQueue, QueueItem};
21
21
+
use crate::renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind};
22
22
+
23
23
+
const DEFAULT_DESTINATION_ID: &str = "receiver-0";
24
24
+
25
25
+
enum CastCommand {
26
26
+
Play {
27
27
+
items: Vec<QueueItem>,
28
28
+
start_index: usize,
29
29
+
reply: oneshot::Sender<Result<()>>,
30
30
+
},
31
31
+
Enqueue {
32
32
+
items: Vec<QueueItem>,
33
33
+
reply: oneshot::Sender<Result<()>>,
34
34
+
},
35
35
+
PlayNext {
36
36
+
items: Vec<QueueItem>,
37
37
+
reply: oneshot::Sender<Result<()>>,
38
38
+
},
39
39
+
Pause(oneshot::Sender<Result<()>>),
40
40
+
Resume(oneshot::Sender<Result<()>>),
41
41
+
Stop(oneshot::Sender<Result<()>>),
42
42
+
Next(oneshot::Sender<Result<()>>),
43
43
+
Previous(oneshot::Sender<Result<()>>),
44
44
+
Seek(f64, oneshot::Sender<Result<()>>),
45
45
+
Volume(f32, oneshot::Sender<Result<()>>),
46
46
+
Shutdown,
47
47
+
}
48
48
+
49
49
+
pub struct ChromecastRenderer {
50
50
+
device: CastDevice,
51
51
+
tx: std_mpsc::Sender<CastCommand>,
52
52
+
state: Arc<Mutex<PlaybackState>>,
53
53
+
queue: PlaybackQueue,
54
54
+
}
55
55
+
56
56
+
impl ChromecastRenderer {
57
57
+
/// Connect to a Chromecast and launch the Default Media Receiver.
58
58
+
pub async fn connect(device: CastDevice) -> Result<Self> {
59
59
+
let state = Arc::new(Mutex::new(PlaybackState::default()));
60
60
+
let queue = PlaybackQueue::new();
61
61
+
let (tx, rx) = std_mpsc::channel::<CastCommand>();
62
62
+
63
63
+
let dev_for_thread = device.clone();
64
64
+
let state_for_thread = state.clone();
65
65
+
let queue_for_thread = queue.clone();
66
66
+
67
67
+
let (ready_tx, ready_rx) = oneshot::channel();
68
68
+
69
69
+
thread::spawn(move || {
70
70
+
if let Err(e) = cast_worker(
71
71
+
dev_for_thread,
72
72
+
rx,
73
73
+
state_for_thread,
74
74
+
queue_for_thread,
75
75
+
ready_tx,
76
76
+
) {
77
77
+
error!(error=?e, "chromecast worker exited with error");
78
78
+
}
79
79
+
});
80
80
+
81
81
+
ready_rx
82
82
+
.await
83
83
+
.context("chromecast worker dropped ready signal")??;
84
84
+
85
85
+
Ok(Self {
86
86
+
device,
87
87
+
tx,
88
88
+
state,
89
89
+
queue,
90
90
+
})
91
91
+
}
92
92
+
93
93
+
pub fn device(&self) -> &CastDevice {
94
94
+
&self.device
95
95
+
}
96
96
+
97
97
+
pub fn queue_handle(&self) -> PlaybackQueue {
98
98
+
self.queue.clone()
99
99
+
}
100
100
+
101
101
+
async fn send(
102
102
+
&self,
103
103
+
make: impl FnOnce(oneshot::Sender<Result<()>>) -> CastCommand,
104
104
+
) -> Result<()> {
105
105
+
let (tx, rx) = oneshot::channel();
106
106
+
self.tx
107
107
+
.send(make(tx))
108
108
+
.map_err(|_| anyhow!("chromecast worker dead"))?;
109
109
+
rx.await.context("chromecast reply dropped")?
110
110
+
}
111
111
+
}
112
112
+
113
113
+
impl Drop for ChromecastRenderer {
114
114
+
fn drop(&mut self) {
115
115
+
let _ = self.tx.send(CastCommand::Shutdown);
116
116
+
}
117
117
+
}
118
118
+
119
119
+
fn build_media(item: &QueueItem) -> Media {
120
120
+
let images: Vec<Image> = item
121
121
+
.image_url
122
122
+
.clone()
123
123
+
.map(|u| vec![Image::new(u)])
124
124
+
.unwrap_or_default();
125
125
+
let metadata = if item.is_video {
126
126
+
Metadata::Movie(MovieMediaMetadata {
127
127
+
title: Some(item.title.clone()),
128
128
+
subtitle: if item.subtitle.is_empty() {
129
129
+
None
130
130
+
} else {
131
131
+
Some(item.subtitle.clone())
132
132
+
},
133
133
+
studio: None,
134
134
+
release_date: None,
135
135
+
images,
136
136
+
})
137
137
+
} else {
138
138
+
Metadata::MusicTrack(MusicTrackMediaMetadata {
139
139
+
album_name: None,
140
140
+
title: Some(item.title.clone()),
141
141
+
album_artist: None,
142
142
+
artist: if item.subtitle.is_empty() {
143
143
+
None
144
144
+
} else {
145
145
+
Some(item.subtitle.clone())
146
146
+
},
147
147
+
composer: None,
148
148
+
track_number: None,
149
149
+
disc_number: None,
150
150
+
images,
151
151
+
release_date: None,
152
152
+
})
153
153
+
};
154
154
+
Media {
155
155
+
content_id: item.stream_url.clone(),
156
156
+
content_type: item.content_type.clone(),
157
157
+
stream_type: StreamType::Buffered,
158
158
+
duration: item.duration_secs.map(|d| d as f32),
159
159
+
metadata: Some(metadata),
160
160
+
}
161
161
+
}
162
162
+
163
163
+
struct Session {
164
164
+
app_transport: String,
165
165
+
session_id: String,
166
166
+
media_session_id: Option<i32>,
167
167
+
}
168
168
+
169
169
+
fn cast_worker(
170
170
+
device: CastDevice,
171
171
+
rx: std_mpsc::Receiver<CastCommand>,
172
172
+
state: Arc<Mutex<PlaybackState>>,
173
173
+
queue: PlaybackQueue,
174
174
+
ready: oneshot::Sender<Result<()>>,
175
175
+
) -> Result<()> {
176
176
+
let host = device.address.to_string();
177
177
+
let rc = match RcDevice::connect_without_host_verification(host.clone(), device.port) {
178
178
+
Ok(d) => d,
179
179
+
Err(e) => {
180
180
+
let _ = ready.send(Err(anyhow!("connect to {}: {}", device.display_name(), e)));
181
181
+
return Ok(());
182
182
+
}
183
183
+
};
184
184
+
185
185
+
if let Err(e) = rc.connection.connect(DEFAULT_DESTINATION_ID) {
186
186
+
let _ = ready.send(Err(anyhow!("initial connect: {}", e)));
187
187
+
return Ok(());
188
188
+
}
189
189
+
if let Err(e) = rc.heartbeat.ping() {
190
190
+
let _ = ready.send(Err(anyhow!("heartbeat ping: {}", e)));
191
191
+
return Ok(());
192
192
+
}
193
193
+
194
194
+
let app = match rc.receiver.launch_app(&CastDeviceApp::DefaultMediaReceiver) {
195
195
+
Ok(a) => a,
196
196
+
Err(e) => {
197
197
+
let _ = ready.send(Err(anyhow!("launching default media receiver: {}", e)));
198
198
+
return Ok(());
199
199
+
}
200
200
+
};
201
201
+
if let Err(e) = rc.connection.connect(app.transport_id.as_str()) {
202
202
+
let _ = ready.send(Err(anyhow!("connect to app: {}", e)));
203
203
+
return Ok(());
204
204
+
}
205
205
+
206
206
+
let session = Arc::new(Mutex::new(Session {
207
207
+
app_transport: app.transport_id.clone(),
208
208
+
session_id: app.session_id.clone(),
209
209
+
media_session_id: None,
210
210
+
}));
211
211
+
212
212
+
let _ = ready.send(Ok(()));
213
213
+
214
214
+
let mut last_heartbeat = Instant::now();
215
215
+
let mut last_status = Instant::now();
216
216
+
217
217
+
// We run the worker single-threaded — rust_cast's channels aren't
218
218
+
// thread-safe. Between commands we poll media status for progress
219
219
+
// updates and auto-advance on FINISHED.
220
220
+
loop {
221
221
+
match rx.recv_timeout(Duration::from_millis(300)) {
222
222
+
Ok(CastCommand::Shutdown) => {
223
223
+
let sess = session.lock();
224
224
+
let _ = rc.receiver.stop_app(sess.session_id.as_str());
225
225
+
return Ok(());
226
226
+
}
227
227
+
Ok(cmd) => handle_command(cmd, &rc, &session, &queue, &state),
228
228
+
Err(std_mpsc::RecvTimeoutError::Timeout) => {}
229
229
+
Err(std_mpsc::RecvTimeoutError::Disconnected) => return Ok(()),
230
230
+
}
231
231
+
232
232
+
if last_heartbeat.elapsed() > Duration::from_secs(4) {
233
233
+
let _ = rc.heartbeat.ping();
234
234
+
last_heartbeat = Instant::now();
235
235
+
}
236
236
+
237
237
+
if last_status.elapsed() > Duration::from_millis(750) {
238
238
+
last_status = Instant::now();
239
239
+
let (dest, media_id) = {
240
240
+
let sess = session.lock();
241
241
+
(sess.app_transport.clone(), sess.media_session_id)
242
242
+
};
243
243
+
if let Some(id) = media_id {
244
244
+
if let Ok(status) = rc.media.get_status(dest.as_str(), Some(id)) {
245
245
+
let mut advanced = false;
246
246
+
if let Some(entry) = status.entries.first() {
247
247
+
let mut s = state.lock();
248
248
+
s.position_secs = entry.current_time.unwrap_or(0.0) as f64;
249
249
+
if let Some(m) = &entry.media {
250
250
+
if let Some(d) = m.duration {
251
251
+
s.duration_secs = d as f64;
252
252
+
}
253
253
+
}
254
254
+
match entry.player_state {
255
255
+
PlayerState::Playing => s.status = PlaybackStatus::Playing,
256
256
+
PlayerState::Paused => s.status = PlaybackStatus::Paused,
257
257
+
PlayerState::Buffering => s.status = PlaybackStatus::Buffering,
258
258
+
PlayerState::Idle => {
259
259
+
if matches!(entry.idle_reason, Some(IdleReason::Finished)) {
260
260
+
advanced = true;
261
261
+
} else {
262
262
+
s.status = PlaybackStatus::Idle;
263
263
+
}
264
264
+
}
265
265
+
}
266
266
+
}
267
267
+
if advanced {
268
268
+
if queue.advance().is_some() {
269
269
+
if let Some(next) = queue.current() {
270
270
+
let _ = load_media(&rc, &session, &next, &state);
271
271
+
}
272
272
+
} else {
273
273
+
state.lock().status = PlaybackStatus::Idle;
274
274
+
}
275
275
+
}
276
276
+
}
277
277
+
}
278
278
+
}
279
279
+
}
280
280
+
}
281
281
+
282
282
+
fn handle_command(
283
283
+
cmd: CastCommand,
284
284
+
rc: &RcDevice,
285
285
+
session: &Arc<Mutex<Session>>,
286
286
+
queue: &PlaybackQueue,
287
287
+
state: &Arc<Mutex<PlaybackState>>,
288
288
+
) {
289
289
+
match cmd {
290
290
+
CastCommand::Play {
291
291
+
items,
292
292
+
start_index,
293
293
+
reply,
294
294
+
} => {
295
295
+
queue.replace(items.clone(), start_index);
296
296
+
let index = queue.current_index().unwrap_or(0);
297
297
+
let current = items.get(index).cloned();
298
298
+
{
299
299
+
let mut s = state.lock();
300
300
+
s.queue = queue.items();
301
301
+
s.current_index = queue.current_index();
302
302
+
s.now_playing = current.clone();
303
303
+
s.status = PlaybackStatus::Buffering;
304
304
+
}
305
305
+
let res = if let Some(item) = current {
306
306
+
load_media(rc, session, &item, state)
307
307
+
} else {
308
308
+
Ok(())
309
309
+
};
310
310
+
let _ = reply.send(res);
311
311
+
}
312
312
+
CastCommand::Enqueue { items, reply } => {
313
313
+
let was_empty = queue.is_empty();
314
314
+
queue.append(items);
315
315
+
let res = if was_empty {
316
316
+
if let Some(item) = queue.current() {
317
317
+
load_media(rc, session, &item, state)
318
318
+
} else {
319
319
+
Ok(())
320
320
+
}
321
321
+
} else {
322
322
+
Ok(())
323
323
+
};
324
324
+
{
325
325
+
let mut s = state.lock();
326
326
+
s.queue = queue.items();
327
327
+
s.current_index = queue.current_index();
328
328
+
s.now_playing = queue.current();
329
329
+
}
330
330
+
let _ = reply.send(res);
331
331
+
}
332
332
+
CastCommand::PlayNext { items, reply } => {
333
333
+
let was_empty = queue.is_empty();
334
334
+
queue.insert_next(items);
335
335
+
let res = if was_empty {
336
336
+
if let Some(item) = queue.current() {
337
337
+
load_media(rc, session, &item, state)
338
338
+
} else {
339
339
+
Ok(())
340
340
+
}
341
341
+
} else {
342
342
+
Ok(())
343
343
+
};
344
344
+
{
345
345
+
let mut s = state.lock();
346
346
+
s.queue = queue.items();
347
347
+
s.current_index = queue.current_index();
348
348
+
s.now_playing = queue.current();
349
349
+
}
350
350
+
let _ = reply.send(res);
351
351
+
}
352
352
+
CastCommand::Pause(reply) => {
353
353
+
let sess = session.lock();
354
354
+
let media_id = sess.media_session_id;
355
355
+
let dest = sess.app_transport.clone();
356
356
+
drop(sess);
357
357
+
let res = if let Some(id) = media_id {
358
358
+
rc.media
359
359
+
.pause(dest.as_str(), id)
360
360
+
.map(|_| ())
361
361
+
.map_err(|e| anyhow!("pause: {}", e))
362
362
+
} else {
363
363
+
Ok(())
364
364
+
};
365
365
+
let _ = reply.send(res);
366
366
+
}
367
367
+
CastCommand::Resume(reply) => {
368
368
+
let sess = session.lock();
369
369
+
let media_id = sess.media_session_id;
370
370
+
let dest = sess.app_transport.clone();
371
371
+
drop(sess);
372
372
+
let res = if let Some(id) = media_id {
373
373
+
rc.media
374
374
+
.play(dest.as_str(), id)
375
375
+
.map(|_| ())
376
376
+
.map_err(|e| anyhow!("resume: {}", e))
377
377
+
} else {
378
378
+
Ok(())
379
379
+
};
380
380
+
let _ = reply.send(res);
381
381
+
}
382
382
+
CastCommand::Stop(reply) => {
383
383
+
let sess = session.lock();
384
384
+
let media_id = sess.media_session_id;
385
385
+
let dest = sess.app_transport.clone();
386
386
+
drop(sess);
387
387
+
queue.clear();
388
388
+
{
389
389
+
let mut s = state.lock();
390
390
+
s.queue.clear();
391
391
+
s.now_playing = None;
392
392
+
s.current_index = None;
393
393
+
s.status = PlaybackStatus::Stopped;
394
394
+
}
395
395
+
let res = if let Some(id) = media_id {
396
396
+
rc.media
397
397
+
.stop(dest.as_str(), id)
398
398
+
.map(|_| ())
399
399
+
.map_err(|e| anyhow!("stop: {}", e))
400
400
+
} else {
401
401
+
Ok(())
402
402
+
};
403
403
+
let _ = reply.send(res);
404
404
+
}
405
405
+
CastCommand::Next(reply) => {
406
406
+
let res = if queue.advance().is_some() {
407
407
+
if let Some(item) = queue.current() {
408
408
+
load_media(rc, session, &item, state)
409
409
+
} else {
410
410
+
Ok(())
411
411
+
}
412
412
+
} else {
413
413
+
Ok(())
414
414
+
};
415
415
+
let _ = reply.send(res);
416
416
+
}
417
417
+
CastCommand::Previous(reply) => {
418
418
+
let res = if queue.back().is_some() {
419
419
+
if let Some(item) = queue.current() {
420
420
+
load_media(rc, session, &item, state)
421
421
+
} else {
422
422
+
Ok(())
423
423
+
}
424
424
+
} else {
425
425
+
Ok(())
426
426
+
};
427
427
+
let _ = reply.send(res);
428
428
+
}
429
429
+
CastCommand::Seek(pos, reply) => {
430
430
+
let sess = session.lock();
431
431
+
let media_id = sess.media_session_id;
432
432
+
let dest = sess.app_transport.clone();
433
433
+
drop(sess);
434
434
+
let res = if let Some(id) = media_id {
435
435
+
rc.media
436
436
+
.seek(dest.as_str(), id, Some(pos as f32), None)
437
437
+
.map(|_| ())
438
438
+
.map_err(|e| anyhow!("seek: {}", e))
439
439
+
} else {
440
440
+
Ok(())
441
441
+
};
442
442
+
let _ = reply.send(res);
443
443
+
}
444
444
+
CastCommand::Volume(v, reply) => {
445
445
+
let res = rc
446
446
+
.receiver
447
447
+
.set_volume(v.clamp(0.0, 1.0))
448
448
+
.map(|_| ())
449
449
+
.map_err(|e| anyhow!("volume: {}", e));
450
450
+
if res.is_ok() {
451
451
+
state.lock().volume = v.clamp(0.0, 1.0);
452
452
+
}
453
453
+
let _ = reply.send(res);
454
454
+
}
455
455
+
CastCommand::Shutdown => {
456
456
+
let sess = session.lock();
457
457
+
let _ = rc.receiver.stop_app(sess.session_id.as_str());
458
458
+
}
459
459
+
}
460
460
+
}
461
461
+
462
462
+
fn load_media(
463
463
+
rc: &RcDevice,
464
464
+
session: &Arc<Mutex<Session>>,
465
465
+
item: &QueueItem,
466
466
+
state: &Arc<Mutex<PlaybackState>>,
467
467
+
) -> Result<()> {
468
468
+
let media = build_media(item);
469
469
+
let sess = session.lock();
470
470
+
let dest = sess.app_transport.clone();
471
471
+
let sid = sess.session_id.clone();
472
472
+
drop(sess);
473
473
+
let status = rc
474
474
+
.media
475
475
+
.load(dest.as_str(), sid.as_str(), &media)
476
476
+
.map_err(|e| anyhow!("cast load: {}", e))?;
477
477
+
if let Some(entry) = status.entries.first() {
478
478
+
session.lock().media_session_id = Some(entry.media_session_id);
479
479
+
let mut s = state.lock();
480
480
+
s.duration_secs = entry.media.as_ref().and_then(|m| m.duration).unwrap_or(0.0) as f64;
481
481
+
s.now_playing = Some(item.clone());
482
482
+
s.status = PlaybackStatus::Playing;
483
483
+
}
484
484
+
Ok(())
485
485
+
}
486
486
+
487
487
+
#[async_trait]
488
488
+
impl Renderer for ChromecastRenderer {
489
489
+
fn kind(&self) -> RendererKind {
490
490
+
RendererKind::Chromecast
491
491
+
}
492
492
+
493
493
+
async fn play(&self, items: Vec<QueueItem>, start_index: usize) -> Result<()> {
494
494
+
self.send(|reply| CastCommand::Play {
495
495
+
items,
496
496
+
start_index,
497
497
+
reply,
498
498
+
})
499
499
+
.await
500
500
+
}
501
501
+
502
502
+
async fn enqueue(&self, items: Vec<QueueItem>) -> Result<()> {
503
503
+
self.send(|reply| CastCommand::Enqueue { items, reply })
504
504
+
.await
505
505
+
}
506
506
+
507
507
+
async fn play_next(&self, items: Vec<QueueItem>) -> Result<()> {
508
508
+
self.send(|reply| CastCommand::PlayNext { items, reply })
509
509
+
.await
510
510
+
}
511
511
+
512
512
+
async fn pause(&self) -> Result<()> {
513
513
+
self.send(CastCommand::Pause).await
514
514
+
}
515
515
+
516
516
+
async fn resume(&self) -> Result<()> {
517
517
+
self.send(CastCommand::Resume).await
518
518
+
}
519
519
+
520
520
+
async fn stop(&self) -> Result<()> {
521
521
+
self.send(CastCommand::Stop).await
522
522
+
}
523
523
+
524
524
+
async fn next(&self) -> Result<()> {
525
525
+
self.send(CastCommand::Next).await
526
526
+
}
527
527
+
528
528
+
async fn previous(&self) -> Result<()> {
529
529
+
self.send(CastCommand::Previous).await
530
530
+
}
531
531
+
532
532
+
async fn seek(&self, position_secs: f64) -> Result<()> {
533
533
+
self.send(|reply| CastCommand::Seek(position_secs, reply))
534
534
+
.await
535
535
+
}
536
536
+
537
537
+
async fn set_volume(&self, volume: f32) -> Result<()> {
538
538
+
self.send(|reply| CastCommand::Volume(volume, reply)).await
539
539
+
}
540
540
+
541
541
+
fn state(&self) -> PlaybackState {
542
542
+
self.state.lock().clone()
543
543
+
}
544
544
+
}
···
1
1
+
use std::collections::HashMap;
2
2
+
use std::net::IpAddr;
3
3
+
use std::time::Duration;
4
4
+
5
5
+
use anyhow::Result;
6
6
+
use mdns_sd::{ServiceDaemon, ServiceEvent};
7
7
+
use tokio::time::timeout;
8
8
+
9
9
+
const CAST_SERVICE: &str = "_googlecast._tcp.local.";
10
10
+
11
11
+
#[derive(Debug, Clone)]
12
12
+
pub struct CastDevice {
13
13
+
pub name: String,
14
14
+
pub model: String,
15
15
+
pub address: IpAddr,
16
16
+
pub port: u16,
17
17
+
pub uuid: String,
18
18
+
}
19
19
+
20
20
+
impl CastDevice {
21
21
+
pub fn display_name(&self) -> String {
22
22
+
if self.name.is_empty() {
23
23
+
self.model.clone()
24
24
+
} else {
25
25
+
self.name.clone()
26
26
+
}
27
27
+
}
28
28
+
}
29
29
+
30
30
+
/// Browse the local network for Chromecast-compatible receivers.
31
31
+
pub async fn discover_chromecasts(scan_for: Duration) -> Result<Vec<CastDevice>> {
32
32
+
let mdns = ServiceDaemon::new()?;
33
33
+
let receiver = mdns.browse(CAST_SERVICE)?;
34
34
+
let mut devices: HashMap<String, CastDevice> = HashMap::new();
35
35
+
36
36
+
let deadline = tokio::time::Instant::now() + scan_for;
37
37
+
while tokio::time::Instant::now() < deadline {
38
38
+
let remaining = deadline
39
39
+
.saturating_duration_since(tokio::time::Instant::now())
40
40
+
.min(Duration::from_millis(500));
41
41
+
let ev = timeout(remaining, async {
42
42
+
loop {
43
43
+
match receiver.recv_async().await {
44
44
+
Ok(ev) => return Some(ev),
45
45
+
Err(_) => return None,
46
46
+
}
47
47
+
}
48
48
+
})
49
49
+
.await;
50
50
+
let ev = match ev {
51
51
+
Ok(Some(ev)) => ev,
52
52
+
_ => continue,
53
53
+
};
54
54
+
if let ServiceEvent::ServiceResolved(info) = ev {
55
55
+
let props: HashMap<String, String> = info
56
56
+
.get_properties()
57
57
+
.iter()
58
58
+
.map(|p| (p.key().to_string(), p.val_str().to_string()))
59
59
+
.collect();
60
60
+
let friendly = props
61
61
+
.get("fn")
62
62
+
.cloned()
63
63
+
.unwrap_or_else(|| info.get_hostname().to_string());
64
64
+
let model = props.get("md").cloned().unwrap_or_default();
65
65
+
let uuid = props
66
66
+
.get("id")
67
67
+
.cloned()
68
68
+
.unwrap_or_else(|| info.get_fullname().to_string());
69
69
+
let Some(addr) = info.get_addresses().iter().next().copied() else {
70
70
+
continue;
71
71
+
};
72
72
+
let port = info.get_port();
73
73
+
devices.insert(
74
74
+
uuid.clone(),
75
75
+
CastDevice {
76
76
+
name: friendly,
77
77
+
model,
78
78
+
address: addr,
79
79
+
port,
80
80
+
uuid,
81
81
+
},
82
82
+
);
83
83
+
}
84
84
+
}
85
85
+
let _ = mdns.shutdown();
86
86
+
let mut list: Vec<_> = devices.into_values().collect();
87
87
+
list.sort_by(|a, b| a.display_name().cmp(&b.display_name()));
88
88
+
Ok(list)
89
89
+
}
···
1
1
+
pub mod cast;
2
2
+
pub mod discovery;
3
3
+
pub mod mpv;
4
4
+
pub mod queue;
5
5
+
pub mod renderer;
6
6
+
7
7
+
pub use cast::ChromecastRenderer;
8
8
+
pub use discovery::{discover_chromecasts, CastDevice};
9
9
+
pub use mpv::MpvRenderer;
10
10
+
pub use queue::{PlaybackQueue, QueueItem};
11
11
+
pub use renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind};
···
1
1
+
use std::path::PathBuf;
2
2
+
use std::process::Stdio;
3
3
+
use std::sync::atomic::{AtomicU32, Ordering};
4
4
+
use std::sync::Arc;
5
5
+
use std::time::Duration;
6
6
+
7
7
+
use anyhow::{anyhow, Context, Result};
8
8
+
use async_trait::async_trait;
9
9
+
use parking_lot::Mutex;
10
10
+
use serde_json::{json, Value};
11
11
+
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
12
12
+
use tokio::net::UnixStream;
13
13
+
use tokio::process::{Child, Command};
14
14
+
use tokio::sync::{mpsc, oneshot};
15
15
+
use tokio::time::sleep;
16
16
+
use tracing::{debug, error, warn};
17
17
+
18
18
+
use crate::queue::{PlaybackQueue, QueueItem};
19
19
+
use crate::renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind};
20
20
+
21
21
+
/// mpv renderer — spawns a headless mpv (or a windowed one, for video)
22
22
+
/// and drives it over the JSON IPC socket.
23
23
+
pub struct MpvRenderer {
24
24
+
socket_path: PathBuf,
25
25
+
force_window: bool,
26
26
+
queue: PlaybackQueue,
27
27
+
state: Arc<Mutex<PlaybackState>>,
28
28
+
ipc: Arc<Mutex<Option<IpcHandle>>>,
29
29
+
child: Arc<Mutex<Option<Child>>>,
30
30
+
}
31
31
+
32
32
+
#[derive(Clone)]
33
33
+
struct IpcHandle {
34
34
+
tx: mpsc::UnboundedSender<IpcCommand>,
35
35
+
req_id: Arc<AtomicU32>,
36
36
+
}
37
37
+
38
38
+
struct IpcCommand {
39
39
+
payload: Value,
40
40
+
reply: Option<oneshot::Sender<Result<Value>>>,
41
41
+
}
42
42
+
43
43
+
impl MpvRenderer {
44
44
+
pub fn new(socket_path: Option<PathBuf>) -> Self {
45
45
+
let socket_path = socket_path.unwrap_or_else(|| {
46
46
+
let mut p = std::env::temp_dir();
47
47
+
p.push(format!("fin-mpv-{}.sock", std::process::id()));
48
48
+
p
49
49
+
});
50
50
+
Self {
51
51
+
socket_path,
52
52
+
force_window: true,
53
53
+
queue: PlaybackQueue::new(),
54
54
+
state: Arc::new(Mutex::new(PlaybackState::default())),
55
55
+
ipc: Arc::new(Mutex::new(None)),
56
56
+
child: Arc::new(Mutex::new(None)),
57
57
+
}
58
58
+
}
59
59
+
60
60
+
pub fn queue_handle(&self) -> PlaybackQueue {
61
61
+
self.queue.clone()
62
62
+
}
63
63
+
64
64
+
/// Ensures mpv is spawned and the IPC channel is ready. Idempotent —
65
65
+
/// calling it a second time is a no-op.
66
66
+
async fn ensure_running(&self) -> Result<()> {
67
67
+
if self.ipc.lock().is_some() {
68
68
+
return Ok(());
69
69
+
}
70
70
+
71
71
+
// If a stale socket exists, remove it first.
72
72
+
let _ = std::fs::remove_file(&self.socket_path);
73
73
+
74
74
+
let mut cmd = Command::new("mpv");
75
75
+
cmd.arg("--idle=yes")
76
76
+
.arg("--no-terminal")
77
77
+
.arg("--force-window=".to_string() + if self.force_window { "immediate" } else { "no" })
78
78
+
.arg("--keep-open=yes")
79
79
+
.arg("--audio-display=no")
80
80
+
.arg("--input-ipc-server=".to_string() + self.socket_path.to_string_lossy().as_ref())
81
81
+
.stdin(Stdio::null())
82
82
+
.stdout(Stdio::null())
83
83
+
.stderr(Stdio::null());
84
84
+
85
85
+
let child = cmd.spawn().context(
86
86
+
"failed to spawn mpv — is it installed? On macOS: `brew install mpv`, on Debian/Ubuntu: `sudo apt install mpv`",
87
87
+
)?;
88
88
+
*self.child.lock() = Some(child);
89
89
+
90
90
+
// Wait for mpv to create the socket. We poll with a small backoff.
91
91
+
let mut stream = None;
92
92
+
for _ in 0..40 {
93
93
+
match UnixStream::connect(&self.socket_path).await {
94
94
+
Ok(s) => {
95
95
+
stream = Some(s);
96
96
+
break;
97
97
+
}
98
98
+
Err(_) => sleep(Duration::from_millis(75)).await,
99
99
+
}
100
100
+
}
101
101
+
let stream = stream.context("timed out connecting to mpv IPC socket")?;
102
102
+
103
103
+
let (tx, rx) = mpsc::unbounded_channel::<IpcCommand>();
104
104
+
let req_id = Arc::new(AtomicU32::new(1));
105
105
+
let handle = IpcHandle {
106
106
+
tx: tx.clone(),
107
107
+
req_id: req_id.clone(),
108
108
+
};
109
109
+
*self.ipc.lock() = Some(handle);
110
110
+
111
111
+
let state = self.state.clone();
112
112
+
let queue = self.queue.clone();
113
113
+
tokio::spawn(async move {
114
114
+
if let Err(e) = ipc_loop(stream, rx, state, queue).await {
115
115
+
error!(error=?e, "mpv ipc loop exited");
116
116
+
}
117
117
+
});
118
118
+
119
119
+
// Observe events we care about — send directly on the mpsc,
120
120
+
// bypassing `cmd()` to avoid recursion through `ensure_running`.
121
121
+
for prop in [
122
122
+
"time-pos",
123
123
+
"duration",
124
124
+
"pause",
125
125
+
"volume",
126
126
+
"playlist-pos",
127
127
+
"eof-reached",
128
128
+
] {
129
129
+
let id = req_id.fetch_add(1, Ordering::SeqCst);
130
130
+
let _ = tx.send(IpcCommand {
131
131
+
payload: json!({
132
132
+
"command": ["observe_property", 1, prop],
133
133
+
"request_id": id,
134
134
+
}),
135
135
+
reply: None,
136
136
+
});
137
137
+
}
138
138
+
Ok(())
139
139
+
}
140
140
+
141
141
+
async fn cmd(&self, mut payload: Value) -> Result<Value> {
142
142
+
self.ensure_running().await?;
143
143
+
// Clone the handle out of the lock so we don't hold a MutexGuard
144
144
+
// across the following `.await` (parking_lot guards aren't Send).
145
145
+
let handle: IpcHandle = {
146
146
+
let guard = self.ipc.lock();
147
147
+
guard
148
148
+
.as_ref()
149
149
+
.cloned()
150
150
+
.ok_or_else(|| anyhow!("mpv not running"))?
151
151
+
};
152
152
+
let id = handle.req_id.fetch_add(1, Ordering::SeqCst);
153
153
+
if let Value::Object(ref mut m) = payload {
154
154
+
m.insert("request_id".into(), Value::from(id));
155
155
+
}
156
156
+
let (reply_tx, reply_rx) = oneshot::channel();
157
157
+
handle
158
158
+
.tx
159
159
+
.send(IpcCommand {
160
160
+
payload,
161
161
+
reply: Some(reply_tx),
162
162
+
})
163
163
+
.map_err(|_| anyhow!("mpv ipc channel closed"))?;
164
164
+
reply_rx.await.context("mpv ipc reply dropped")?
165
165
+
}
166
166
+
167
167
+
fn apply_queue_from_local(&self) {
168
168
+
let items = self.queue.items();
169
169
+
let idx = self.queue.current_index();
170
170
+
let mut s = self.state.lock();
171
171
+
s.queue = items.clone();
172
172
+
s.current_index = idx;
173
173
+
s.now_playing = idx.and_then(|i| items.get(i).cloned());
174
174
+
}
175
175
+
}
176
176
+
177
177
+
async fn ipc_loop(
178
178
+
stream: UnixStream,
179
179
+
mut rx: mpsc::UnboundedReceiver<IpcCommand>,
180
180
+
state: Arc<Mutex<PlaybackState>>,
181
181
+
queue: PlaybackQueue,
182
182
+
) -> Result<()> {
183
183
+
let (reader, mut writer) = stream.into_split();
184
184
+
let mut lines = BufReader::new(reader).lines();
185
185
+
let pending: Arc<Mutex<std::collections::HashMap<u32, oneshot::Sender<Result<Value>>>>> =
186
186
+
Arc::new(Mutex::new(Default::default()));
187
187
+
188
188
+
let pending_read = pending.clone();
189
189
+
let state_r = state.clone();
190
190
+
let queue_r = queue.clone();
191
191
+
let read_task = tokio::spawn(async move {
192
192
+
while let Ok(Some(line)) = lines.next_line().await {
193
193
+
let value: Value = match serde_json::from_str(&line) {
194
194
+
Ok(v) => v,
195
195
+
Err(_) => continue,
196
196
+
};
197
197
+
if let Some(id) = value.get("request_id").and_then(|v| v.as_u64()) {
198
198
+
let sender = pending_read.lock().remove(&(id as u32));
199
199
+
if let Some(tx) = sender {
200
200
+
let err = value
201
201
+
.get("error")
202
202
+
.and_then(|v| v.as_str())
203
203
+
.unwrap_or("success");
204
204
+
if err == "success" {
205
205
+
let _ = tx.send(Ok(value.get("data").cloned().unwrap_or(Value::Null)));
206
206
+
} else {
207
207
+
let _ = tx.send(Err(anyhow!("mpv ipc error: {}", err)));
208
208
+
}
209
209
+
}
210
210
+
continue;
211
211
+
}
212
212
+
if let Some(event) = value.get("event").and_then(|v| v.as_str()) {
213
213
+
handle_event(event, &value, &state_r, &queue_r);
214
214
+
}
215
215
+
}
216
216
+
});
217
217
+
218
218
+
while let Some(cmd) = rx.recv().await {
219
219
+
let id = cmd
220
220
+
.payload
221
221
+
.get("request_id")
222
222
+
.and_then(|v| v.as_u64())
223
223
+
.unwrap_or(0) as u32;
224
224
+
if let Some(reply) = cmd.reply {
225
225
+
pending.lock().insert(id, reply);
226
226
+
}
227
227
+
let mut serialized = serde_json::to_vec(&cmd.payload)?;
228
228
+
serialized.push(b'\n');
229
229
+
if let Err(e) = writer.write_all(&serialized).await {
230
230
+
warn!(?e, "mpv ipc write failed");
231
231
+
break;
232
232
+
}
233
233
+
}
234
234
+
read_task.abort();
235
235
+
Ok(())
236
236
+
}
237
237
+
238
238
+
fn handle_event(
239
239
+
event: &str,
240
240
+
value: &Value,
241
241
+
state: &Arc<Mutex<PlaybackState>>,
242
242
+
queue: &PlaybackQueue,
243
243
+
) {
244
244
+
match event {
245
245
+
"property-change" => {
246
246
+
let name = value.get("name").and_then(|v| v.as_str()).unwrap_or("");
247
247
+
let data = value.get("data").cloned().unwrap_or(Value::Null);
248
248
+
let mut s = state.lock();
249
249
+
match name {
250
250
+
"time-pos" => {
251
251
+
s.position_secs = data.as_f64().unwrap_or(s.position_secs);
252
252
+
}
253
253
+
"duration" => {
254
254
+
s.duration_secs = data.as_f64().unwrap_or(s.duration_secs);
255
255
+
}
256
256
+
"pause" => {
257
257
+
if let Some(p) = data.as_bool() {
258
258
+
s.status = if p {
259
259
+
PlaybackStatus::Paused
260
260
+
} else if s.now_playing.is_some() {
261
261
+
PlaybackStatus::Playing
262
262
+
} else {
263
263
+
PlaybackStatus::Idle
264
264
+
};
265
265
+
}
266
266
+
}
267
267
+
"volume" => {
268
268
+
if let Some(v) = data.as_f64() {
269
269
+
s.volume = (v / 100.0) as f32;
270
270
+
}
271
271
+
}
272
272
+
_ => {}
273
273
+
}
274
274
+
}
275
275
+
"start-file" => {
276
276
+
let mut s = state.lock();
277
277
+
s.status = PlaybackStatus::Buffering;
278
278
+
}
279
279
+
"file-loaded" => {
280
280
+
let mut s = state.lock();
281
281
+
s.status = PlaybackStatus::Playing;
282
282
+
}
283
283
+
"end-file" => {
284
284
+
queue.advance();
285
285
+
let items = queue.items();
286
286
+
let idx = queue.current_index();
287
287
+
let mut s = state.lock();
288
288
+
s.queue = items.clone();
289
289
+
s.current_index = idx;
290
290
+
s.now_playing = idx.and_then(|i| items.get(i).cloned());
291
291
+
if s.now_playing.is_none() {
292
292
+
s.status = PlaybackStatus::Idle;
293
293
+
s.position_secs = 0.0;
294
294
+
s.duration_secs = 0.0;
295
295
+
}
296
296
+
}
297
297
+
"shutdown" => {
298
298
+
let mut s = state.lock();
299
299
+
s.status = PlaybackStatus::Stopped;
300
300
+
}
301
301
+
_ => {
302
302
+
debug!(event, "mpv event");
303
303
+
}
304
304
+
}
305
305
+
}
306
306
+
307
307
+
#[async_trait]
308
308
+
impl Renderer for MpvRenderer {
309
309
+
fn kind(&self) -> RendererKind {
310
310
+
RendererKind::Mpv
311
311
+
}
312
312
+
313
313
+
async fn play(&self, items: Vec<QueueItem>, start_index: usize) -> Result<()> {
314
314
+
self.ensure_running().await?;
315
315
+
self.queue.replace(items.clone(), start_index);
316
316
+
self.apply_queue_from_local();
317
317
+
318
318
+
// Reset mpv's own playlist and load our items.
319
319
+
self.cmd(json!({"command": ["playlist-clear"]})).await.ok();
320
320
+
self.cmd(json!({"command": ["stop"]})).await.ok();
321
321
+
322
322
+
for (i, item) in items.iter().enumerate() {
323
323
+
let mode = if i == 0 { "replace" } else { "append" };
324
324
+
self.cmd(json!({"command": ["loadfile", item.stream_url, mode]}))
325
325
+
.await?;
326
326
+
}
327
327
+
if start_index > 0 {
328
328
+
self.cmd(json!({"command": ["set_property", "playlist-pos", start_index]}))
329
329
+
.await?;
330
330
+
}
331
331
+
self.cmd(json!({"command": ["set_property", "pause", false]}))
332
332
+
.await?;
333
333
+
Ok(())
334
334
+
}
335
335
+
336
336
+
async fn enqueue(&self, items: Vec<QueueItem>) -> Result<()> {
337
337
+
self.ensure_running().await?;
338
338
+
self.queue.append(items.clone());
339
339
+
self.apply_queue_from_local();
340
340
+
for item in items {
341
341
+
self.cmd(json!({"command": ["loadfile", item.stream_url, "append-play"]}))
342
342
+
.await?;
343
343
+
}
344
344
+
Ok(())
345
345
+
}
346
346
+
347
347
+
async fn play_next(&self, items: Vec<QueueItem>) -> Result<()> {
348
348
+
self.ensure_running().await?;
349
349
+
self.queue.insert_next(items.clone());
350
350
+
self.apply_queue_from_local();
351
351
+
// mpv's `loadfile` with `insert-next` is available since mpv 0.38.
352
352
+
for item in items {
353
353
+
self.cmd(json!({"command": ["loadfile", item.stream_url, "insert-next"]}))
354
354
+
.await?;
355
355
+
}
356
356
+
Ok(())
357
357
+
}
358
358
+
359
359
+
async fn pause(&self) -> Result<()> {
360
360
+
self.cmd(json!({"command": ["set_property", "pause", true]}))
361
361
+
.await
362
362
+
.map(|_| ())
363
363
+
}
364
364
+
365
365
+
async fn resume(&self) -> Result<()> {
366
366
+
self.cmd(json!({"command": ["set_property", "pause", false]}))
367
367
+
.await
368
368
+
.map(|_| ())
369
369
+
}
370
370
+
371
371
+
async fn stop(&self) -> Result<()> {
372
372
+
self.queue.clear();
373
373
+
self.apply_queue_from_local();
374
374
+
self.cmd(json!({"command": ["stop"]})).await.map(|_| ())
375
375
+
}
376
376
+
377
377
+
async fn next(&self) -> Result<()> {
378
378
+
self.cmd(json!({"command": ["playlist-next", "weak"]}))
379
379
+
.await
380
380
+
.map(|_| ())
381
381
+
}
382
382
+
383
383
+
async fn previous(&self) -> Result<()> {
384
384
+
self.cmd(json!({"command": ["playlist-prev", "weak"]}))
385
385
+
.await
386
386
+
.map(|_| ())
387
387
+
}
388
388
+
389
389
+
async fn seek(&self, position_secs: f64) -> Result<()> {
390
390
+
self.cmd(json!({"command": ["seek", position_secs, "absolute"]}))
391
391
+
.await
392
392
+
.map(|_| ())
393
393
+
}
394
394
+
395
395
+
async fn set_volume(&self, volume: f32) -> Result<()> {
396
396
+
let pct = (volume.clamp(0.0, 1.5) * 100.0) as i32;
397
397
+
self.cmd(json!({"command": ["set_property", "volume", pct]}))
398
398
+
.await
399
399
+
.map(|_| ())
400
400
+
}
401
401
+
402
402
+
fn state(&self) -> PlaybackState {
403
403
+
self.state.lock().clone()
404
404
+
}
405
405
+
}
406
406
+
407
407
+
impl Drop for MpvRenderer {
408
408
+
fn drop(&mut self) {
409
409
+
if let Some(mut child) = self.child.lock().take() {
410
410
+
let _ = child.start_kill();
411
411
+
}
412
412
+
let _ = std::fs::remove_file(&self.socket_path);
413
413
+
}
414
414
+
}
···
1
1
+
use std::sync::Arc;
2
2
+
3
3
+
use parking_lot::RwLock;
4
4
+
use serde::{Deserialize, Serialize};
5
5
+
6
6
+
/// Everything a renderer needs to play a single item.
7
7
+
#[derive(Debug, Clone, Serialize, Deserialize)]
8
8
+
pub struct QueueItem {
9
9
+
pub id: String,
10
10
+
pub title: String,
11
11
+
pub subtitle: String,
12
12
+
pub stream_url: String,
13
13
+
pub image_url: Option<String>,
14
14
+
pub duration_secs: Option<u64>,
15
15
+
pub is_video: bool,
16
16
+
pub content_type: String,
17
17
+
}
18
18
+
19
19
+
/// A thread-safe playback queue. Both the TUI and renderers share the same
20
20
+
/// queue snapshot; the renderer is the source of truth for the current index.
21
21
+
#[derive(Debug, Clone, Default)]
22
22
+
pub struct PlaybackQueue {
23
23
+
inner: Arc<RwLock<QueueInner>>,
24
24
+
}
25
25
+
26
26
+
#[derive(Debug, Default)]
27
27
+
struct QueueInner {
28
28
+
items: Vec<QueueItem>,
29
29
+
index: Option<usize>,
30
30
+
}
31
31
+
32
32
+
impl PlaybackQueue {
33
33
+
pub fn new() -> Self {
34
34
+
Self::default()
35
35
+
}
36
36
+
37
37
+
pub fn items(&self) -> Vec<QueueItem> {
38
38
+
self.inner.read().items.clone()
39
39
+
}
40
40
+
41
41
+
pub fn current_index(&self) -> Option<usize> {
42
42
+
self.inner.read().index
43
43
+
}
44
44
+
45
45
+
pub fn current(&self) -> Option<QueueItem> {
46
46
+
let g = self.inner.read();
47
47
+
g.index.and_then(|i| g.items.get(i).cloned())
48
48
+
}
49
49
+
50
50
+
pub fn len(&self) -> usize {
51
51
+
self.inner.read().items.len()
52
52
+
}
53
53
+
54
54
+
pub fn is_empty(&self) -> bool {
55
55
+
self.inner.read().items.is_empty()
56
56
+
}
57
57
+
58
58
+
pub fn replace(&self, items: Vec<QueueItem>, index: usize) {
59
59
+
let mut g = self.inner.write();
60
60
+
g.items = items;
61
61
+
g.index = if g.items.is_empty() {
62
62
+
None
63
63
+
} else {
64
64
+
Some(index.min(g.items.len().saturating_sub(1)))
65
65
+
};
66
66
+
}
67
67
+
68
68
+
pub fn append(&self, items: Vec<QueueItem>) {
69
69
+
let mut g = self.inner.write();
70
70
+
let was_empty = g.items.is_empty();
71
71
+
g.items.extend(items);
72
72
+
if was_empty && !g.items.is_empty() {
73
73
+
g.index = Some(0);
74
74
+
}
75
75
+
}
76
76
+
77
77
+
pub fn insert_next(&self, items: Vec<QueueItem>) {
78
78
+
let mut g = self.inner.write();
79
79
+
let at = g.index.map(|i| i + 1).unwrap_or(0);
80
80
+
for (offset, item) in items.into_iter().enumerate() {
81
81
+
g.items.insert(at + offset, item);
82
82
+
}
83
83
+
if g.index.is_none() && !g.items.is_empty() {
84
84
+
g.index = Some(0);
85
85
+
}
86
86
+
}
87
87
+
88
88
+
pub fn advance(&self) -> Option<usize> {
89
89
+
let mut g = self.inner.write();
90
90
+
let next = match g.index {
91
91
+
Some(i) if i + 1 < g.items.len() => Some(i + 1),
92
92
+
None if !g.items.is_empty() => Some(0),
93
93
+
_ => None,
94
94
+
};
95
95
+
g.index = next;
96
96
+
next
97
97
+
}
98
98
+
99
99
+
pub fn back(&self) -> Option<usize> {
100
100
+
let mut g = self.inner.write();
101
101
+
let prev = match g.index {
102
102
+
Some(i) if i > 0 => Some(i - 1),
103
103
+
_ => g.index,
104
104
+
};
105
105
+
g.index = prev;
106
106
+
prev
107
107
+
}
108
108
+
109
109
+
pub fn set_index(&self, i: usize) {
110
110
+
let mut g = self.inner.write();
111
111
+
if i < g.items.len() {
112
112
+
g.index = Some(i);
113
113
+
}
114
114
+
}
115
115
+
116
116
+
pub fn clear(&self) {
117
117
+
let mut g = self.inner.write();
118
118
+
g.items.clear();
119
119
+
g.index = None;
120
120
+
}
121
121
+
122
122
+
pub fn remove(&self, i: usize) {
123
123
+
let mut g = self.inner.write();
124
124
+
if i >= g.items.len() {
125
125
+
return;
126
126
+
}
127
127
+
g.items.remove(i);
128
128
+
if g.items.is_empty() {
129
129
+
g.index = None;
130
130
+
} else if let Some(cur) = g.index {
131
131
+
if i < cur {
132
132
+
g.index = Some(cur - 1);
133
133
+
} else if i == cur && cur >= g.items.len() {
134
134
+
g.index = Some(g.items.len() - 1);
135
135
+
}
136
136
+
}
137
137
+
}
138
138
+
}
···
1
1
+
use async_trait::async_trait;
2
2
+
use serde::{Deserialize, Serialize};
3
3
+
4
4
+
use crate::queue::QueueItem;
5
5
+
6
6
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7
7
+
#[serde(rename_all = "lowercase")]
8
8
+
pub enum PlaybackStatus {
9
9
+
Idle,
10
10
+
Buffering,
11
11
+
Playing,
12
12
+
Paused,
13
13
+
Stopped,
14
14
+
}
15
15
+
16
16
+
#[derive(Debug, Clone, Serialize, Deserialize)]
17
17
+
pub struct PlaybackState {
18
18
+
pub status: PlaybackStatus,
19
19
+
pub position_secs: f64,
20
20
+
pub duration_secs: f64,
21
21
+
pub volume: f32,
22
22
+
pub now_playing: Option<QueueItem>,
23
23
+
pub queue: Vec<QueueItem>,
24
24
+
pub current_index: Option<usize>,
25
25
+
}
26
26
+
27
27
+
impl Default for PlaybackState {
28
28
+
fn default() -> Self {
29
29
+
Self {
30
30
+
status: PlaybackStatus::Idle,
31
31
+
position_secs: 0.0,
32
32
+
duration_secs: 0.0,
33
33
+
volume: 1.0,
34
34
+
now_playing: None,
35
35
+
queue: Vec::new(),
36
36
+
current_index: None,
37
37
+
}
38
38
+
}
39
39
+
}
40
40
+
41
41
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42
42
+
#[serde(rename_all = "lowercase")]
43
43
+
pub enum RendererKind {
44
44
+
Mpv,
45
45
+
Chromecast,
46
46
+
}
47
47
+
48
48
+
impl RendererKind {
49
49
+
pub fn label(&self) -> &'static str {
50
50
+
match self {
51
51
+
Self::Mpv => "mpv",
52
52
+
Self::Chromecast => "chromecast",
53
53
+
}
54
54
+
}
55
55
+
}
56
56
+
57
57
+
/// Common interface implemented by both the local mpv renderer and the
58
58
+
/// Chromecast renderer. Every method is async because Chromecast operations
59
59
+
/// round-trip a TLS control connection.
60
60
+
#[async_trait]
61
61
+
pub trait Renderer: Send + Sync {
62
62
+
fn kind(&self) -> RendererKind;
63
63
+
64
64
+
/// Replace the queue and start playing at `start_index`.
65
65
+
async fn play(&self, items: Vec<QueueItem>, start_index: usize) -> anyhow::Result<()>;
66
66
+
67
67
+
/// Append items to the current queue without interrupting playback.
68
68
+
async fn enqueue(&self, items: Vec<QueueItem>) -> anyhow::Result<()>;
69
69
+
70
70
+
/// Insert items at the front of the queue (play next).
71
71
+
async fn play_next(&self, items: Vec<QueueItem>) -> anyhow::Result<()>;
72
72
+
73
73
+
async fn pause(&self) -> anyhow::Result<()>;
74
74
+
async fn resume(&self) -> anyhow::Result<()>;
75
75
+
async fn stop(&self) -> anyhow::Result<()>;
76
76
+
async fn next(&self) -> anyhow::Result<()>;
77
77
+
async fn previous(&self) -> anyhow::Result<()>;
78
78
+
async fn seek(&self, position_secs: f64) -> anyhow::Result<()>;
79
79
+
async fn set_volume(&self, volume: f32) -> anyhow::Result<()>;
80
80
+
81
81
+
fn state(&self) -> PlaybackState;
82
82
+
}
···
1
1
+
[package]
2
2
+
name = "fin-tui"
3
3
+
version.workspace = true
4
4
+
edition.workspace = true
5
5
+
authors.workspace = true
6
6
+
license.workspace = true
7
7
+
repository.workspace = true
8
8
+
9
9
+
[dependencies]
10
10
+
anyhow.workspace = true
11
11
+
thiserror.workspace = true
12
12
+
serde.workspace = true
13
13
+
serde_json.workspace = true
14
14
+
tokio.workspace = true
15
15
+
futures.workspace = true
16
16
+
async-trait.workspace = true
17
17
+
ratatui.workspace = true
18
18
+
crossterm.workspace = true
19
19
+
tracing.workspace = true
20
20
+
unicode-width.workspace = true
21
21
+
parking_lot.workspace = true
22
22
+
chrono.workspace = true
23
23
+
fin-config.workspace = true
24
24
+
fin-jellyfin.workspace = true
25
25
+
fin-player.workspace = true
···
1
1
+
use std::io;
2
2
+
use std::sync::atomic::{AtomicU64, Ordering};
3
3
+
use std::sync::Arc;
4
4
+
use std::time::Duration;
5
5
+
6
6
+
use anyhow::{anyhow, Context, Result};
7
7
+
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8
8
+
use crossterm::execute;
9
9
+
use crossterm::terminal::{
10
10
+
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
11
11
+
};
12
12
+
use parking_lot::Mutex;
13
13
+
use ratatui::backend::CrosstermBackend;
14
14
+
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Margin, Rect};
15
15
+
use ratatui::style::{Modifier, Style};
16
16
+
use ratatui::text::{Line, Span};
17
17
+
use ratatui::widgets::{List, ListItem, ListState, Paragraph, Wrap};
18
18
+
use ratatui::{Frame, Terminal};
19
19
+
use tokio::sync::mpsc;
20
20
+
use tracing::warn;
21
21
+
22
22
+
use fin_config::{Config, RendererPref};
23
23
+
use fin_jellyfin::{BaseItem, ItemKind, JellyfinClient, StreamFormat};
24
24
+
use fin_player::{
25
25
+
discover_chromecasts, CastDevice, ChromecastRenderer, MpvRenderer, PlaybackState, QueueItem,
26
26
+
Renderer, RendererKind,
27
27
+
};
28
28
+
29
29
+
use crate::event::{spawn_event_loop, AppEvent};
30
30
+
use crate::screens::{item_row_line, Screen};
31
31
+
use crate::theme::{accent_style, base_style, muted_style, title_style, Palette};
32
32
+
use crate::widgets::{neon_block, NeonTabs, PlayerBar};
33
33
+
34
34
+
/// Everything the render loop needs.
35
35
+
pub struct App {
36
36
+
pub config: Arc<Mutex<Config>>,
37
37
+
pub jellyfin: Arc<Mutex<Arc<JellyfinClient>>>,
38
38
+
pub renderer: Arc<Mutex<Arc<dyn Renderer>>>,
39
39
+
pub renderer_kind: Arc<Mutex<RendererKind>>,
40
40
+
pub renderer_label: Arc<Mutex<String>>,
41
41
+
screen: Screen,
42
42
+
// shared display state
43
43
+
home_recent: Arc<Mutex<Vec<BaseItem>>>,
44
44
+
home_resume: Arc<Mutex<Vec<BaseItem>>>,
45
45
+
music_items: Arc<Mutex<Vec<BaseItem>>>,
46
46
+
video_items: Arc<Mutex<Vec<BaseItem>>>,
47
47
+
playlists: Arc<Mutex<Vec<BaseItem>>>,
48
48
+
playlist_items: Arc<Mutex<Vec<BaseItem>>>,
49
49
+
open_playlist: Arc<Mutex<Option<BaseItem>>>,
50
50
+
search_results: Arc<Mutex<Vec<BaseItem>>>,
51
51
+
devices: Arc<Mutex<Vec<CastDevice>>>,
52
52
+
search_generation: Arc<AtomicU64>,
53
53
+
// ephemeral
54
54
+
search_query: String,
55
55
+
search_input_focused: bool,
56
56
+
list_state: ListState,
57
57
+
status_message: Arc<Mutex<Option<String>>>,
58
58
+
playback_state: Arc<Mutex<PlaybackState>>,
59
59
+
should_quit: bool,
60
60
+
logo_pulse: u8,
61
61
+
}
62
62
+
63
63
+
impl App {
64
64
+
pub fn new(config: Config, jellyfin: JellyfinClient, renderer: Arc<dyn Renderer>) -> Self {
65
65
+
let kind = renderer.kind();
66
66
+
let label = match kind {
67
67
+
RendererKind::Mpv => "this machine".into(),
68
68
+
RendererKind::Chromecast => "Chromecast".into(),
69
69
+
};
70
70
+
let mut list_state = ListState::default();
71
71
+
list_state.select(Some(0));
72
72
+
Self {
73
73
+
config: Arc::new(Mutex::new(config)),
74
74
+
jellyfin: Arc::new(Mutex::new(Arc::new(jellyfin))),
75
75
+
renderer: Arc::new(Mutex::new(renderer)),
76
76
+
renderer_kind: Arc::new(Mutex::new(kind)),
77
77
+
renderer_label: Arc::new(Mutex::new(label)),
78
78
+
screen: Screen::Home,
79
79
+
home_recent: Arc::new(Mutex::new(vec![])),
80
80
+
home_resume: Arc::new(Mutex::new(vec![])),
81
81
+
music_items: Arc::new(Mutex::new(vec![])),
82
82
+
video_items: Arc::new(Mutex::new(vec![])),
83
83
+
playlists: Arc::new(Mutex::new(vec![])),
84
84
+
playlist_items: Arc::new(Mutex::new(vec![])),
85
85
+
open_playlist: Arc::new(Mutex::new(None)),
86
86
+
search_results: Arc::new(Mutex::new(vec![])),
87
87
+
devices: Arc::new(Mutex::new(vec![])),
88
88
+
search_generation: Arc::new(AtomicU64::new(0)),
89
89
+
search_query: String::new(),
90
90
+
search_input_focused: true,
91
91
+
list_state,
92
92
+
status_message: Arc::new(Mutex::new(None)),
93
93
+
playback_state: Arc::new(Mutex::new(PlaybackState::default())),
94
94
+
should_quit: false,
95
95
+
logo_pulse: 0,
96
96
+
}
97
97
+
}
98
98
+
99
99
+
/// Handy accessor — the current Jellyfin client. Swapped out atomically
100
100
+
/// when the user switches servers.
101
101
+
fn jf(&self) -> Arc<JellyfinClient> {
102
102
+
self.jellyfin.lock().clone()
103
103
+
}
104
104
+
105
105
+
fn current_list(&self) -> Vec<BaseItem> {
106
106
+
match self.screen {
107
107
+
Screen::Home => {
108
108
+
let mut v = self.home_resume.lock().clone();
109
109
+
v.extend(self.home_recent.lock().clone());
110
110
+
v
111
111
+
}
112
112
+
Screen::Search => self.search_results.lock().clone(),
113
113
+
Screen::Music => self.music_items.lock().clone(),
114
114
+
Screen::Videos => self.video_items.lock().clone(),
115
115
+
Screen::Playlists => {
116
116
+
if self.open_playlist.lock().is_some() {
117
117
+
self.playlist_items.lock().clone()
118
118
+
} else {
119
119
+
self.playlists.lock().clone()
120
120
+
}
121
121
+
}
122
122
+
Screen::Queue => {
123
123
+
let items = self.playback_state.lock().queue.clone();
124
124
+
items
125
125
+
.into_iter()
126
126
+
.map(|q| BaseItem {
127
127
+
id: q.id,
128
128
+
name: q.title,
129
129
+
type_: if q.is_video {
130
130
+
"Video".into()
131
131
+
} else {
132
132
+
"Audio".into()
133
133
+
},
134
134
+
album: None,
135
135
+
album_id: None,
136
136
+
album_artist: None,
137
137
+
artists: if q.subtitle.is_empty() {
138
138
+
None
139
139
+
} else {
140
140
+
Some(vec![q.subtitle])
141
141
+
},
142
142
+
series_name: None,
143
143
+
production_year: None,
144
144
+
run_time_ticks: q.duration_secs.map(|s| (s * 10_000_000) as i64),
145
145
+
media_type: None,
146
146
+
index_number: None,
147
147
+
parent_index_number: None,
148
148
+
image_tags: None,
149
149
+
is_folder: None,
150
150
+
overview: None,
151
151
+
})
152
152
+
.collect()
153
153
+
}
154
154
+
Screen::Devices | Screen::Settings => vec![],
155
155
+
}
156
156
+
}
157
157
+
158
158
+
fn selected_item(&self) -> Option<BaseItem> {
159
159
+
let list = self.current_list();
160
160
+
self.list_state
161
161
+
.selected()
162
162
+
.and_then(|i| list.get(i).cloned())
163
163
+
}
164
164
+
165
165
+
fn set_status(&self, msg: impl Into<String>) {
166
166
+
*self.status_message.lock() = Some(msg.into());
167
167
+
}
168
168
+
169
169
+
async fn load_screen(&self) {
170
170
+
match self.screen {
171
171
+
Screen::Home => {
172
172
+
let jf = self.jf();
173
173
+
let resume = self.home_resume.clone();
174
174
+
let recent = self.home_recent.clone();
175
175
+
let status = self.status_message.clone();
176
176
+
tokio::spawn(async move {
177
177
+
match jf.resume(12).await {
178
178
+
Ok(v) => *resume.lock() = v,
179
179
+
Err(e) => *status.lock() = Some(format!("resume: {}", e)),
180
180
+
}
181
181
+
match jf.latest(None, 24).await {
182
182
+
Ok(v) => *recent.lock() = v,
183
183
+
Err(e) => *status.lock() = Some(format!("latest: {}", e)),
184
184
+
}
185
185
+
});
186
186
+
}
187
187
+
Screen::Music => {
188
188
+
let jf = self.jf();
189
189
+
let out = self.music_items.clone();
190
190
+
let status = self.status_message.clone();
191
191
+
tokio::spawn(async move {
192
192
+
match jf
193
193
+
.items(None, &["MusicAlbum"], true, Some("SortName"), Some(200))
194
194
+
.await
195
195
+
{
196
196
+
Ok(v) => *out.lock() = v,
197
197
+
Err(e) => *status.lock() = Some(format!("music: {}", e)),
198
198
+
}
199
199
+
});
200
200
+
}
201
201
+
Screen::Videos => {
202
202
+
let jf = self.jf();
203
203
+
let out = self.video_items.clone();
204
204
+
let status = self.status_message.clone();
205
205
+
tokio::spawn(async move {
206
206
+
match jf
207
207
+
.items(
208
208
+
None,
209
209
+
&["Movie", "Series"],
210
210
+
true,
211
211
+
Some("SortName"),
212
212
+
Some(200),
213
213
+
)
214
214
+
.await
215
215
+
{
216
216
+
Ok(v) => *out.lock() = v,
217
217
+
Err(e) => *status.lock() = Some(format!("videos: {}", e)),
218
218
+
}
219
219
+
});
220
220
+
}
221
221
+
Screen::Playlists => {
222
222
+
let jf = self.jf();
223
223
+
let out = self.playlists.clone();
224
224
+
let status = self.status_message.clone();
225
225
+
tokio::spawn(async move {
226
226
+
match jf.playlists().await {
227
227
+
Ok(v) => *out.lock() = v,
228
228
+
Err(e) => *status.lock() = Some(format!("playlists: {}", e)),
229
229
+
}
230
230
+
});
231
231
+
}
232
232
+
Screen::Devices => {
233
233
+
let out = self.devices.clone();
234
234
+
let status = self.status_message.clone();
235
235
+
tokio::spawn(async move {
236
236
+
*status.lock() = Some("Scanning for Chromecasts…".into());
237
237
+
match discover_chromecasts(Duration::from_secs(4)).await {
238
238
+
Ok(v) => {
239
239
+
*status.lock() = Some(format!("Found {} device(s).", v.len()));
240
240
+
*out.lock() = v;
241
241
+
}
242
242
+
Err(e) => *status.lock() = Some(format!("scan: {}", e)),
243
243
+
}
244
244
+
});
245
245
+
}
246
246
+
_ => {}
247
247
+
}
248
248
+
}
249
249
+
250
250
+
/// fzf-style instant search — fires on every keystroke.
251
251
+
/// Uses a generation counter so a slower response for an older query
252
252
+
/// can never overwrite results from a newer one.
253
253
+
fn run_search(&self) {
254
254
+
let query = self.search_query.trim().to_string();
255
255
+
let gen = self.search_generation.fetch_add(1, Ordering::SeqCst) + 1;
256
256
+
if query.is_empty() {
257
257
+
self.search_results.lock().clear();
258
258
+
return;
259
259
+
}
260
260
+
let jf = self.jf();
261
261
+
let out = self.search_results.clone();
262
262
+
let status = self.status_message.clone();
263
263
+
let generation = self.search_generation.clone();
264
264
+
tokio::spawn(async move {
265
265
+
// small debounce lets fast typists coalesce keystrokes
266
266
+
tokio::time::sleep(Duration::from_millis(90)).await;
267
267
+
if generation.load(Ordering::SeqCst) != gen {
268
268
+
return;
269
269
+
}
270
270
+
match jf
271
271
+
.search(
272
272
+
&query,
273
273
+
&[
274
274
+
"Audio",
275
275
+
"MusicAlbum",
276
276
+
"MusicArtist",
277
277
+
"Movie",
278
278
+
"Series",
279
279
+
"Episode",
280
280
+
],
281
281
+
50,
282
282
+
)
283
283
+
.await
284
284
+
{
285
285
+
Ok(v) => {
286
286
+
if generation.load(Ordering::SeqCst) == gen {
287
287
+
*status.lock() = Some(format!("{} match(es) for “{}”", v.len(), query));
288
288
+
*out.lock() = v;
289
289
+
}
290
290
+
}
291
291
+
Err(e) => {
292
292
+
if generation.load(Ordering::SeqCst) == gen {
293
293
+
*status.lock() = Some(format!("search: {}", e));
294
294
+
}
295
295
+
}
296
296
+
}
297
297
+
});
298
298
+
}
299
299
+
300
300
+
fn base_item_to_queue(&self, item: &BaseItem, format: StreamFormat) -> Result<QueueItem> {
301
301
+
let url = self.jf().stream_url(item, format)?;
302
302
+
let is_video = item.kind().is_video();
303
303
+
let content_type = if is_video {
304
304
+
"video/mp4".to_string()
305
305
+
} else {
306
306
+
"audio/mpeg".to_string()
307
307
+
};
308
308
+
let image_url = item
309
309
+
.image_tags
310
310
+
.as_ref()
311
311
+
.and_then(|v| v.get("Primary"))
312
312
+
.and_then(|v| v.as_str())
313
313
+
.map(|tag| self.jf().image_url(&item.id, tag, 480));
314
314
+
Ok(QueueItem {
315
315
+
id: item.id.clone(),
316
316
+
title: item.name.clone(),
317
317
+
subtitle: item.subtitle(),
318
318
+
stream_url: url,
319
319
+
image_url,
320
320
+
duration_secs: item.duration_secs(),
321
321
+
is_video,
322
322
+
content_type,
323
323
+
})
324
324
+
}
325
325
+
326
326
+
async fn expand_playable(&self, item: BaseItem) -> Result<Vec<BaseItem>> {
327
327
+
match item.kind() {
328
328
+
ItemKind::MusicAlbum => {
329
329
+
self.jf()
330
330
+
.items(
331
331
+
Some(&item.id),
332
332
+
&["Audio"],
333
333
+
false,
334
334
+
Some("SortName"),
335
335
+
Some(500),
336
336
+
)
337
337
+
.await
338
338
+
}
339
339
+
ItemKind::MusicArtist => {
340
340
+
self.jf()
341
341
+
.items(Some(&item.id), &["Audio"], true, Some("Album"), Some(500))
342
342
+
.await
343
343
+
}
344
344
+
ItemKind::Playlist => self.jf().playlist_items(&item.id).await,
345
345
+
ItemKind::Series => {
346
346
+
self.jf()
347
347
+
.items(
348
348
+
Some(&item.id),
349
349
+
&["Episode"],
350
350
+
true,
351
351
+
Some("SortName"),
352
352
+
Some(500),
353
353
+
)
354
354
+
.await
355
355
+
}
356
356
+
_ => Ok(vec![item]),
357
357
+
}
358
358
+
}
359
359
+
360
360
+
async fn play_selected(&mut self, mode: PlayMode) {
361
361
+
let Some(item) = self.selected_item() else {
362
362
+
self.set_status("Nothing selected.");
363
363
+
return;
364
364
+
};
365
365
+
// If we're in the Playlists screen with no playlist open, opening the
366
366
+
// playlist should load its contents instead of playing.
367
367
+
if self.screen == Screen::Playlists && self.open_playlist.lock().is_none() {
368
368
+
self.open_playlist_selected(item).await;
369
369
+
return;
370
370
+
}
371
371
+
372
372
+
let items = match self.expand_playable(item).await {
373
373
+
Ok(v) => v,
374
374
+
Err(e) => {
375
375
+
self.set_status(format!("expand: {}", e));
376
376
+
return;
377
377
+
}
378
378
+
};
379
379
+
if items.is_empty() {
380
380
+
self.set_status("Nothing playable here.");
381
381
+
return;
382
382
+
}
383
383
+
let format = match *self.renderer_kind.lock() {
384
384
+
RendererKind::Mpv => StreamFormat::Direct,
385
385
+
RendererKind::Chromecast => StreamFormat::Hls,
386
386
+
};
387
387
+
let mut queue_items = Vec::with_capacity(items.len());
388
388
+
for it in &items {
389
389
+
match self.base_item_to_queue(it, format) {
390
390
+
Ok(q) => queue_items.push(q),
391
391
+
Err(e) => warn!(?e, "skip item"),
392
392
+
}
393
393
+
}
394
394
+
if queue_items.is_empty() {
395
395
+
self.set_status("No playable stream URLs.");
396
396
+
return;
397
397
+
}
398
398
+
let renderer = self.renderer.lock().clone();
399
399
+
let title = queue_items
400
400
+
.first()
401
401
+
.map(|q| q.title.clone())
402
402
+
.unwrap_or_default();
403
403
+
let count = queue_items.len();
404
404
+
let result = match mode {
405
405
+
PlayMode::PlayNow => renderer.play(queue_items, 0).await,
406
406
+
PlayMode::Enqueue => renderer.enqueue(queue_items).await,
407
407
+
PlayMode::PlayNext => renderer.play_next(queue_items).await,
408
408
+
};
409
409
+
match result {
410
410
+
Ok(()) => self.set_status(match mode {
411
411
+
PlayMode::PlayNow => format!("▶ Playing “{}” ({} item(s))", title, count),
412
412
+
PlayMode::Enqueue => format!("+ Queued {} item(s)", count),
413
413
+
PlayMode::PlayNext => format!("↥ Playing next: {} item(s)", count),
414
414
+
}),
415
415
+
Err(e) => self.set_status(format!("renderer: {}", e)),
416
416
+
}
417
417
+
}
418
418
+
419
419
+
async fn open_playlist_selected(&mut self, item: BaseItem) {
420
420
+
let jf = self.jf();
421
421
+
let out = self.playlist_items.clone();
422
422
+
let open = self.open_playlist.clone();
423
423
+
let status = self.status_message.clone();
424
424
+
let id = item.id.clone();
425
425
+
*open.lock() = Some(item);
426
426
+
tokio::spawn(async move {
427
427
+
match jf.playlist_items(&id).await {
428
428
+
Ok(v) => {
429
429
+
*status.lock() = Some(format!("Loaded {} items", v.len()));
430
430
+
*out.lock() = v;
431
431
+
}
432
432
+
Err(e) => *status.lock() = Some(format!("playlist: {}", e)),
433
433
+
}
434
434
+
});
435
435
+
self.list_state.select(Some(0));
436
436
+
}
437
437
+
438
438
+
async fn use_selected_device(&self) -> Result<()> {
439
439
+
let devices = self.devices.lock().clone();
440
440
+
let Some(sel) = self
441
441
+
.list_state
442
442
+
.selected()
443
443
+
.and_then(|i| devices.get(i).cloned())
444
444
+
else {
445
445
+
return Err(anyhow!("no device selected"));
446
446
+
};
447
447
+
self.set_status(format!("Connecting to {}…", sel.display_name()));
448
448
+
let renderer = ChromecastRenderer::connect(sel.clone()).await?;
449
449
+
let arc: Arc<dyn Renderer> = Arc::new(renderer);
450
450
+
*self.renderer.lock() = arc;
451
451
+
*self.renderer_kind.lock() = RendererKind::Chromecast;
452
452
+
*self.renderer_label.lock() = sel.display_name();
453
453
+
// persist preference
454
454
+
{
455
455
+
let mut cfg = self.config.lock();
456
456
+
cfg.renderer = RendererPref::Chromecast;
457
457
+
cfg.last_chromecast = Some(sel.display_name());
458
458
+
let _ = cfg.save();
459
459
+
}
460
460
+
self.set_status(format!("Streaming to {}.", sel.display_name()));
461
461
+
Ok(())
462
462
+
}
463
463
+
464
464
+
/// Switch to a saved server by name — updates config, rebuilds the
465
465
+
/// Jellyfin client, and reloads the current screen.
466
466
+
fn switch_server(&self, name: &str) -> Result<()> {
467
467
+
let server = {
468
468
+
let mut cfg = self.config.lock();
469
469
+
cfg.switch_to(name)?;
470
470
+
cfg.save()?;
471
471
+
cfg.current().cloned()
472
472
+
};
473
473
+
let Some(server) = server else {
474
474
+
return Err(anyhow!("no active server after switch"));
475
475
+
};
476
476
+
let client = JellyfinClient::with_credentials(
477
477
+
&server.url,
478
478
+
&server.device_id,
479
479
+
&server.user_id,
480
480
+
&server.access_token,
481
481
+
)?;
482
482
+
*self.jellyfin.lock() = Arc::new(client);
483
483
+
// Clear cached content — belongs to the *previous* server.
484
484
+
self.home_recent.lock().clear();
485
485
+
self.home_resume.lock().clear();
486
486
+
self.music_items.lock().clear();
487
487
+
self.video_items.lock().clear();
488
488
+
self.playlists.lock().clear();
489
489
+
self.playlist_items.lock().clear();
490
490
+
*self.open_playlist.lock() = None;
491
491
+
self.search_results.lock().clear();
492
492
+
self.set_status(format!(
493
493
+
"◉ Switched to `{}` ({}, as {})",
494
494
+
server.name, server.url, server.user_name
495
495
+
));
496
496
+
Ok(())
497
497
+
}
498
498
+
499
499
+
fn cycle_server(&self) {
500
500
+
let new_name = {
501
501
+
let mut cfg = self.config.lock();
502
502
+
cfg.cycle_next().map(|s| s.name.clone())
503
503
+
};
504
504
+
let Some(name) = new_name else {
505
505
+
self.set_status("No other server to switch to.");
506
506
+
return;
507
507
+
};
508
508
+
if let Err(e) = self.switch_server(&name) {
509
509
+
self.set_status(format!("switch: {}", e));
510
510
+
}
511
511
+
}
512
512
+
513
513
+
async fn switch_to_mpv(&self) {
514
514
+
let renderer = MpvRenderer::new(None);
515
515
+
let arc: Arc<dyn Renderer> = Arc::new(renderer);
516
516
+
*self.renderer.lock() = arc;
517
517
+
*self.renderer_kind.lock() = RendererKind::Mpv;
518
518
+
*self.renderer_label.lock() = "this machine".into();
519
519
+
{
520
520
+
let mut cfg = self.config.lock();
521
521
+
cfg.renderer = RendererPref::Mpv;
522
522
+
let _ = cfg.save();
523
523
+
}
524
524
+
self.set_status("Streaming to local mpv.");
525
525
+
}
526
526
+
}
527
527
+
528
528
+
#[derive(Debug, Clone, Copy)]
529
529
+
enum PlayMode {
530
530
+
PlayNow,
531
531
+
Enqueue,
532
532
+
PlayNext,
533
533
+
}
534
534
+
535
535
+
pub async fn run_tui(app: App) -> Result<()> {
536
536
+
enable_raw_mode()?;
537
537
+
let mut stdout = io::stdout();
538
538
+
execute!(stdout, EnterAlternateScreen)?;
539
539
+
let backend = CrosstermBackend::new(stdout);
540
540
+
let mut terminal = Terminal::new(backend)?;
541
541
+
542
542
+
let mut app = app;
543
543
+
let mut events = spawn_event_loop(Duration::from_millis(200));
544
544
+
app.load_screen().await;
545
545
+
546
546
+
let result = event_loop(&mut app, &mut terminal, &mut events).await;
547
547
+
548
548
+
disable_raw_mode()?;
549
549
+
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
550
550
+
terminal.show_cursor()?;
551
551
+
result
552
552
+
}
553
553
+
554
554
+
async fn event_loop(
555
555
+
app: &mut App,
556
556
+
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
557
557
+
events: &mut mpsc::UnboundedReceiver<AppEvent>,
558
558
+
) -> Result<()> {
559
559
+
while !app.should_quit {
560
560
+
// Refresh live playback state each tick.
561
561
+
*app.playback_state.lock() = app.renderer.lock().state();
562
562
+
563
563
+
terminal.draw(|f| draw(f, app))?;
564
564
+
565
565
+
if let Some(ev) = events.recv().await {
566
566
+
match ev {
567
567
+
AppEvent::Tick => {
568
568
+
app.logo_pulse = app.logo_pulse.wrapping_add(1);
569
569
+
}
570
570
+
AppEvent::Resize(_, _) => {}
571
571
+
AppEvent::Key(k) => handle_key(app, k).await?,
572
572
+
}
573
573
+
} else {
574
574
+
break;
575
575
+
}
576
576
+
}
577
577
+
Ok(())
578
578
+
}
579
579
+
580
580
+
async fn handle_key(app: &mut App, key: KeyEvent) -> Result<()> {
581
581
+
// fzf-style search: the input eats printable keys and updates results on
582
582
+
// every keystroke. Arrow keys still navigate the results list; Enter plays
583
583
+
// the highlighted item.
584
584
+
if app.screen == Screen::Search && app.search_input_focused {
585
585
+
match key.code {
586
586
+
KeyCode::Esc => {
587
587
+
app.search_input_focused = false;
588
588
+
}
589
589
+
KeyCode::Enter => {
590
590
+
app.play_selected(PlayMode::PlayNow).await;
591
591
+
}
592
592
+
KeyCode::Down => {
593
593
+
let len = app.current_list().len();
594
594
+
let i = app.list_state.selected().unwrap_or(0);
595
595
+
if len > 0 {
596
596
+
app.list_state.select(Some((i + 1).min(len - 1)));
597
597
+
}
598
598
+
}
599
599
+
KeyCode::Up => {
600
600
+
let i = app.list_state.selected().unwrap_or(0);
601
601
+
app.list_state.select(Some(i.saturating_sub(1)));
602
602
+
}
603
603
+
KeyCode::Backspace => {
604
604
+
app.search_query.pop();
605
605
+
app.list_state.select(Some(0));
606
606
+
app.run_search();
607
607
+
}
608
608
+
KeyCode::Tab => {
609
609
+
app.search_input_focused = false;
610
610
+
}
611
611
+
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
612
612
+
app.should_quit = true;
613
613
+
}
614
614
+
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
615
615
+
app.search_query.clear();
616
616
+
app.run_search();
617
617
+
}
618
618
+
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
619
619
+
app.play_selected(PlayMode::Enqueue).await;
620
620
+
}
621
621
+
KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
622
622
+
app.play_selected(PlayMode::PlayNext).await;
623
623
+
}
624
624
+
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
625
625
+
app.search_query.push(c);
626
626
+
app.list_state.select(Some(0));
627
627
+
app.run_search();
628
628
+
}
629
629
+
_ => {}
630
630
+
}
631
631
+
return Ok(());
632
632
+
}
633
633
+
634
634
+
match (key.code, key.modifiers) {
635
635
+
(KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
636
636
+
app.should_quit = true;
637
637
+
}
638
638
+
(KeyCode::Tab, _) | (KeyCode::Right, KeyModifiers::CONTROL) => {
639
639
+
app.screen = app.screen.next();
640
640
+
app.list_state.select(Some(0));
641
641
+
app.load_screen().await;
642
642
+
}
643
643
+
(KeyCode::BackTab, _) | (KeyCode::Left, KeyModifiers::CONTROL) => {
644
644
+
app.screen = app.screen.prev();
645
645
+
app.list_state.select(Some(0));
646
646
+
app.load_screen().await;
647
647
+
}
648
648
+
(KeyCode::Char('1'), _) => {
649
649
+
app.screen = Screen::Home;
650
650
+
app.list_state.select(Some(0));
651
651
+
app.load_screen().await;
652
652
+
}
653
653
+
(KeyCode::Char('2'), _) => {
654
654
+
app.screen = Screen::Search;
655
655
+
app.list_state.select(Some(0));
656
656
+
app.search_input_focused = true;
657
657
+
}
658
658
+
(KeyCode::Char('3'), _) => {
659
659
+
app.screen = Screen::Music;
660
660
+
app.list_state.select(Some(0));
661
661
+
app.load_screen().await;
662
662
+
}
663
663
+
(KeyCode::Char('4'), _) => {
664
664
+
app.screen = Screen::Videos;
665
665
+
app.list_state.select(Some(0));
666
666
+
app.load_screen().await;
667
667
+
}
668
668
+
(KeyCode::Char('5'), _) => {
669
669
+
app.screen = Screen::Playlists;
670
670
+
*app.open_playlist.lock() = None;
671
671
+
app.list_state.select(Some(0));
672
672
+
app.load_screen().await;
673
673
+
}
674
674
+
(KeyCode::Char('6'), _) => {
675
675
+
app.screen = Screen::Queue;
676
676
+
app.list_state.select(Some(0));
677
677
+
}
678
678
+
(KeyCode::Char('7'), _) => {
679
679
+
app.screen = Screen::Devices;
680
680
+
app.list_state.select(Some(0));
681
681
+
app.load_screen().await;
682
682
+
}
683
683
+
(KeyCode::Char('8'), _) => {
684
684
+
app.screen = Screen::Settings;
685
685
+
app.list_state.select(Some(0));
686
686
+
}
687
687
+
(KeyCode::Char('/'), _) => {
688
688
+
app.screen = Screen::Search;
689
689
+
app.search_input_focused = true;
690
690
+
}
691
691
+
(KeyCode::Char('r'), _) => {
692
692
+
app.load_screen().await;
693
693
+
app.set_status("Refreshed.");
694
694
+
}
695
695
+
(KeyCode::Esc, _) => {
696
696
+
if app.screen == Screen::Playlists && app.open_playlist.lock().is_some() {
697
697
+
*app.open_playlist.lock() = None;
698
698
+
app.playlist_items.lock().clear();
699
699
+
app.list_state.select(Some(0));
700
700
+
}
701
701
+
}
702
702
+
(KeyCode::Down, _) | (KeyCode::Char('j'), _) => {
703
703
+
let len = app.current_list().len();
704
704
+
let i = app.list_state.selected().unwrap_or(0);
705
705
+
if len > 0 {
706
706
+
app.list_state.select(Some((i + 1).min(len - 1)));
707
707
+
}
708
708
+
}
709
709
+
(KeyCode::Up, _) | (KeyCode::Char('k'), _) => {
710
710
+
let i = app.list_state.selected().unwrap_or(0);
711
711
+
app.list_state.select(Some(i.saturating_sub(1)));
712
712
+
}
713
713
+
(KeyCode::PageDown, _) => {
714
714
+
let len = app.current_list().len();
715
715
+
let i = app.list_state.selected().unwrap_or(0);
716
716
+
if len > 0 {
717
717
+
app.list_state.select(Some((i + 10).min(len - 1)));
718
718
+
}
719
719
+
}
720
720
+
(KeyCode::PageUp, _) => {
721
721
+
let i = app.list_state.selected().unwrap_or(0);
722
722
+
app.list_state.select(Some(i.saturating_sub(10)));
723
723
+
}
724
724
+
(KeyCode::Enter, _) => match app.screen {
725
725
+
Screen::Devices => {
726
726
+
if let Err(e) = app.use_selected_device().await {
727
727
+
app.set_status(format!("device: {}", e));
728
728
+
}
729
729
+
}
730
730
+
Screen::Settings => {
731
731
+
let name = {
732
732
+
let cfg = app.config.lock();
733
733
+
app.list_state
734
734
+
.selected()
735
735
+
.and_then(|i| cfg.servers.get(i).map(|s| s.name.clone()))
736
736
+
};
737
737
+
if let Some(name) = name {
738
738
+
if let Err(e) = app.switch_server(&name) {
739
739
+
app.set_status(format!("switch: {}", e));
740
740
+
} else {
741
741
+
app.load_screen().await;
742
742
+
}
743
743
+
}
744
744
+
}
745
745
+
_ => app.play_selected(PlayMode::PlayNow).await,
746
746
+
},
747
747
+
(KeyCode::Char('a'), _) => app.play_selected(PlayMode::Enqueue).await,
748
748
+
(KeyCode::Char('n'), _) => app.play_selected(PlayMode::PlayNext).await,
749
749
+
(KeyCode::Char(' '), _) | (KeyCode::Char('p'), _) => {
750
750
+
let renderer = app.renderer.lock().clone();
751
751
+
let state = renderer.state();
752
752
+
let _ = match state.status {
753
753
+
fin_player::PlaybackStatus::Playing => renderer.pause().await,
754
754
+
_ => renderer.resume().await,
755
755
+
};
756
756
+
}
757
757
+
(KeyCode::Char('s'), _) => {
758
758
+
let renderer = app.renderer.lock().clone();
759
759
+
let _ = renderer.stop().await;
760
760
+
}
761
761
+
(KeyCode::Char('>'), _) | (KeyCode::Char('l'), _) => {
762
762
+
let renderer = app.renderer.lock().clone();
763
763
+
let _ = renderer.next().await;
764
764
+
}
765
765
+
(KeyCode::Char('<'), _) | (KeyCode::Char('h'), _) => {
766
766
+
let renderer = app.renderer.lock().clone();
767
767
+
let _ = renderer.previous().await;
768
768
+
}
769
769
+
(KeyCode::Char('+'), _) | (KeyCode::Char('='), _) => {
770
770
+
let renderer = app.renderer.lock().clone();
771
771
+
let v = renderer.state().volume + 0.05;
772
772
+
let _ = renderer.set_volume(v.min(1.5)).await;
773
773
+
}
774
774
+
(KeyCode::Char('-'), _) | (KeyCode::Char('_'), _) => {
775
775
+
let renderer = app.renderer.lock().clone();
776
776
+
let v = renderer.state().volume - 0.05;
777
777
+
let _ = renderer.set_volume(v.max(0.0)).await;
778
778
+
}
779
779
+
(KeyCode::Char('m'), _) => {
780
780
+
app.switch_to_mpv().await;
781
781
+
}
782
782
+
(KeyCode::Char('t'), _) => {
783
783
+
app.cycle_server();
784
784
+
app.load_screen().await;
785
785
+
}
786
786
+
_ => {}
787
787
+
}
788
788
+
Ok(())
789
789
+
}
790
790
+
791
791
+
fn draw(f: &mut Frame<'_>, app: &mut App) {
792
792
+
let size = f.area();
793
793
+
f.render_widget(Paragraph::new("").style(base_style()), size);
794
794
+
let chunks = Layout::default()
795
795
+
.direction(Direction::Vertical)
796
796
+
.constraints([
797
797
+
Constraint::Length(3), // header
798
798
+
Constraint::Length(3), // tabs
799
799
+
Constraint::Min(4), // main
800
800
+
Constraint::Length(6), // player bar
801
801
+
Constraint::Length(1), // status/help
802
802
+
])
803
803
+
.split(size);
804
804
+
805
805
+
draw_header(f, chunks[0], app);
806
806
+
draw_tabs(f, chunks[1], app);
807
807
+
draw_body(f, chunks[2], app);
808
808
+
draw_player_bar(f, chunks[3], app);
809
809
+
draw_status_bar(f, chunks[4], app);
810
810
+
}
811
811
+
812
812
+
fn draw_header(f: &mut Frame<'_>, area: Rect, app: &App) {
813
813
+
let pulse = ((app.logo_pulse as f32 * 0.15).sin() * 0.5 + 0.5) * 60.0 + 195.0;
814
814
+
let r = 0u8;
815
815
+
let g = pulse as u8;
816
816
+
let b = (pulse * 0.85) as u8;
817
817
+
let subtitle_col = ratatui::style::Color::Rgb(r, g, b);
818
818
+
let (server, user, server_name, servers_total) = {
819
819
+
let cfg = app.config.lock();
820
820
+
let cur = cfg.current();
821
821
+
(
822
822
+
cur.map(|s| s.url.clone())
823
823
+
.unwrap_or_else(|| "not logged in".into()),
824
824
+
cur.map(|s| s.user_name.clone())
825
825
+
.unwrap_or_else(|| "guest".into()),
826
826
+
cur.map(|s| s.name.clone()).unwrap_or_default(),
827
827
+
cfg.servers.len(),
828
828
+
)
829
829
+
};
830
830
+
let servers_badge = if servers_total > 1 {
831
831
+
format!(" ⇄ {} servers", servers_total)
832
832
+
} else {
833
833
+
String::new()
834
834
+
};
835
835
+
let line = Line::from(vec![
836
836
+
Span::styled(
837
837
+
" ⚡ fin ",
838
838
+
Style::default()
839
839
+
.fg(Palette::PRIMARY)
840
840
+
.add_modifier(Modifier::BOLD),
841
841
+
),
842
842
+
Span::styled(
843
843
+
"— a neon jellyfin client",
844
844
+
Style::default().fg(subtitle_col),
845
845
+
),
846
846
+
Span::styled(
847
847
+
format!(" {} ", user),
848
848
+
Style::default()
849
849
+
.fg(Palette::ACCENT)
850
850
+
.add_modifier(Modifier::BOLD),
851
851
+
),
852
852
+
Span::styled(format!("@ {} ", server), muted_style()),
853
853
+
Span::styled(
854
854
+
if server_name.is_empty() {
855
855
+
String::new()
856
856
+
} else {
857
857
+
format!("[{}]", server_name)
858
858
+
},
859
859
+
Style::default().fg(Palette::HIGHLIGHT),
860
860
+
),
861
861
+
Span::styled(servers_badge, Style::default().fg(Palette::SKY)),
862
862
+
]);
863
863
+
let block = neon_block("", false);
864
864
+
let inner = block.inner(area);
865
865
+
f.render_widget(block, area);
866
866
+
f.render_widget(Paragraph::new(line), inner);
867
867
+
}
868
868
+
869
869
+
fn draw_tabs(f: &mut Frame<'_>, area: Rect, app: &App) {
870
870
+
let block = neon_block("", false);
871
871
+
let inner = block.inner(area);
872
872
+
f.render_widget(block, area);
873
873
+
let labels: Vec<(&str, &str)> = Screen::ALL.iter().map(|s| (s.icon(), s.label())).collect();
874
874
+
let selected = Screen::ALL
875
875
+
.iter()
876
876
+
.position(|s| *s == app.screen)
877
877
+
.unwrap_or(0);
878
878
+
f.render_widget(NeonTabs::new(&labels, selected), inner);
879
879
+
}
880
880
+
881
881
+
fn draw_body(f: &mut Frame<'_>, area: Rect, app: &mut App) {
882
882
+
match app.screen {
883
883
+
Screen::Search => draw_search(f, area, app),
884
884
+
Screen::Devices => draw_devices(f, area, app),
885
885
+
Screen::Settings => draw_settings(f, area, app),
886
886
+
Screen::Playlists if app.open_playlist.lock().is_none() => {
887
887
+
let title = " ▤ Playlists ";
888
888
+
draw_list(f, area, app, title);
889
889
+
}
890
890
+
Screen::Playlists => {
891
891
+
let name = app
892
892
+
.open_playlist
893
893
+
.lock()
894
894
+
.as_ref()
895
895
+
.map(|p| p.name.clone())
896
896
+
.unwrap_or_default();
897
897
+
let title = format!(" ▤ {} — {} tracks ", name, app.playlist_items.lock().len());
898
898
+
draw_list_with_title(f, area, app, &title);
899
899
+
}
900
900
+
Screen::Home => draw_list(
901
901
+
f,
902
902
+
area,
903
903
+
app,
904
904
+
" ◉ Home — Continue Watching / Recently Added ",
905
905
+
),
906
906
+
Screen::Music => draw_list(f, area, app, " ♪ Music — Albums "),
907
907
+
Screen::Videos => draw_list(f, area, app, " ▶ Videos — Movies & Series "),
908
908
+
Screen::Queue => draw_list(f, area, app, " ≡ Queue "),
909
909
+
}
910
910
+
}
911
911
+
912
912
+
fn draw_list(f: &mut Frame<'_>, area: Rect, app: &mut App, title: &str) {
913
913
+
draw_list_with_title(f, area, app, title);
914
914
+
}
915
915
+
916
916
+
fn draw_list_with_title(f: &mut Frame<'_>, area: Rect, app: &mut App, title: &str) {
917
917
+
let items_data = app.current_list();
918
918
+
let items: Vec<ListItem> = items_data
919
919
+
.iter()
920
920
+
.enumerate()
921
921
+
.map(|(i, it)| ListItem::new(item_row_line(it, Some(i) == app.list_state.selected())))
922
922
+
.collect();
923
923
+
if items.is_empty() {
924
924
+
let block = neon_block(title, true);
925
925
+
let inner = block.inner(area);
926
926
+
f.render_widget(block, area);
927
927
+
let msg = match app.screen {
928
928
+
Screen::Queue => "Queue is empty — press Enter on an item to play, `a` to enqueue.",
929
929
+
Screen::Home => "Loading… (press `r` to refresh)",
930
930
+
Screen::Playlists => "No playlists yet.",
931
931
+
_ => "Nothing here.",
932
932
+
};
933
933
+
f.render_widget(
934
934
+
Paragraph::new(Line::from(Span::styled(msg, muted_style())))
935
935
+
.alignment(Alignment::Center),
936
936
+
inner.inner(Margin::new(2, 1)),
937
937
+
);
938
938
+
return;
939
939
+
}
940
940
+
let list = List::new(items)
941
941
+
.block(neon_block(title, true))
942
942
+
.highlight_style(
943
943
+
Style::default()
944
944
+
.bg(Palette::SURFACE)
945
945
+
.fg(Palette::PRIMARY)
946
946
+
.add_modifier(Modifier::BOLD),
947
947
+
)
948
948
+
.highlight_symbol(" ▍ ");
949
949
+
f.render_stateful_widget(list, area, &mut app.list_state);
950
950
+
}
951
951
+
952
952
+
fn draw_search(f: &mut Frame<'_>, area: Rect, app: &mut App) {
953
953
+
let chunks = Layout::default()
954
954
+
.direction(Direction::Vertical)
955
955
+
.constraints([Constraint::Length(3), Constraint::Min(3)])
956
956
+
.split(area);
957
957
+
let block = neon_block(" ⌕ Search ", app.search_input_focused);
958
958
+
let inner = block.inner(chunks[0]);
959
959
+
f.render_widget(block, chunks[0]);
960
960
+
let indicator = if app.search_input_focused {
961
961
+
Span::styled(
962
962
+
"▍ ",
963
963
+
Style::default()
964
964
+
.fg(Palette::PRIMARY)
965
965
+
.add_modifier(Modifier::BOLD),
966
966
+
)
967
967
+
} else {
968
968
+
Span::styled(" ", Style::default())
969
969
+
};
970
970
+
let line = Line::from(vec![
971
971
+
indicator,
972
972
+
Span::styled(
973
973
+
if app.search_query.is_empty() {
974
974
+
"type to search music, movies, series…".to_string()
975
975
+
} else {
976
976
+
app.search_query.clone()
977
977
+
},
978
978
+
if app.search_query.is_empty() {
979
979
+
muted_style()
980
980
+
} else {
981
981
+
Style::default()
982
982
+
.fg(Palette::FG)
983
983
+
.add_modifier(Modifier::BOLD)
984
984
+
},
985
985
+
),
986
986
+
Span::styled(
987
987
+
if app.search_input_focused { "▊" } else { "" },
988
988
+
Style::default()
989
989
+
.fg(Palette::PRIMARY)
990
990
+
.add_modifier(Modifier::SLOW_BLINK),
991
991
+
),
992
992
+
]);
993
993
+
f.render_widget(Paragraph::new(line), inner);
994
994
+
draw_list_with_title(f, chunks[1], app, " ⌕ Results ");
995
995
+
}
996
996
+
997
997
+
fn draw_devices(f: &mut Frame<'_>, area: Rect, app: &mut App) {
998
998
+
let devices = app.devices.lock().clone();
999
999
+
let items: Vec<ListItem> = devices
1000
1000
+
.iter()
1001
1001
+
.enumerate()
1002
1002
+
.map(|(i, d)| {
1003
1003
+
let selected = Some(i) == app.list_state.selected();
1004
1004
+
let icon_col = if selected {
1005
1005
+
Palette::PRIMARY
1006
1006
+
} else {
1007
1007
+
Palette::ACCENT
1008
1008
+
};
1009
1009
+
ListItem::new(Line::from(vec![
1010
1010
+
Span::styled(
1011
1011
+
" ",
1012
1012
+
Style::default().fg(icon_col).add_modifier(Modifier::BOLD),
1013
1013
+
),
1014
1014
+
Span::styled(
1015
1015
+
d.display_name(),
1016
1016
+
Style::default()
1017
1017
+
.fg(Palette::FG)
1018
1018
+
.add_modifier(Modifier::BOLD),
1019
1019
+
),
1020
1020
+
Span::styled(
1021
1021
+
format!(" {} ", d.model),
1022
1022
+
Style::default().fg(Palette::MUTED),
1023
1023
+
),
1024
1024
+
Span::styled(
1025
1025
+
format!("{}:{}", d.address, d.port),
1026
1026
+
Style::default().fg(Palette::SKY),
1027
1027
+
),
1028
1028
+
]))
1029
1029
+
})
1030
1030
+
.collect();
1031
1031
+
let title = " ◈ Chromecast Devices (Enter to select, r to rescan) ";
1032
1032
+
if items.is_empty() {
1033
1033
+
let block = neon_block(title, true);
1034
1034
+
let inner = block.inner(area);
1035
1035
+
f.render_widget(block, area);
1036
1036
+
f.render_widget(
1037
1037
+
Paragraph::new(Line::from(Span::styled(
1038
1038
+
"No devices found yet — press `r` to rescan.",
1039
1039
+
muted_style(),
1040
1040
+
)))
1041
1041
+
.alignment(Alignment::Center),
1042
1042
+
inner.inner(Margin::new(2, 1)),
1043
1043
+
);
1044
1044
+
return;
1045
1045
+
}
1046
1046
+
let list = List::new(items)
1047
1047
+
.block(neon_block(title, true))
1048
1048
+
.highlight_style(
1049
1049
+
Style::default()
1050
1050
+
.bg(Palette::SURFACE)
1051
1051
+
.fg(Palette::PRIMARY)
1052
1052
+
.add_modifier(Modifier::BOLD),
1053
1053
+
)
1054
1054
+
.highlight_symbol(" ▍ ");
1055
1055
+
f.render_stateful_widget(list, area, &mut app.list_state);
1056
1056
+
}
1057
1057
+
1058
1058
+
fn draw_settings(f: &mut Frame<'_>, area: Rect, app: &mut App) {
1059
1059
+
let cfg_snapshot = app.config.lock().clone();
1060
1060
+
let renderer_pref = cfg_snapshot.renderer.label();
1061
1061
+
let last_cast = cfg_snapshot
1062
1062
+
.last_chromecast
1063
1063
+
.clone()
1064
1064
+
.unwrap_or_else(|| "—".into());
1065
1065
+
let path = fin_config::config_path()
1066
1066
+
.map(|p| p.display().to_string())
1067
1067
+
.unwrap_or_default();
1068
1068
+
1069
1069
+
let rows = Layout::default()
1070
1070
+
.direction(Direction::Vertical)
1071
1071
+
.constraints([Constraint::Length(6), Constraint::Min(4)])
1072
1072
+
.split(area);
1073
1073
+
1074
1074
+
// Top card — global settings.
1075
1075
+
let lines = vec![
1076
1076
+
Line::from(vec![
1077
1077
+
Span::styled(" Renderer ", title_style()),
1078
1078
+
Span::styled(renderer_pref, accent_style()),
1079
1079
+
Span::styled(
1080
1080
+
" (press m for mpv, 7 → Enter for a chromecast)",
1081
1081
+
muted_style(),
1082
1082
+
),
1083
1083
+
]),
1084
1084
+
Line::from(vec![
1085
1085
+
Span::styled(" Last Cast ", title_style()),
1086
1086
+
Span::styled(last_cast, Style::default().fg(Palette::HIGHLIGHT)),
1087
1087
+
]),
1088
1088
+
Line::from(vec![
1089
1089
+
Span::styled(" Config File ", title_style()),
1090
1090
+
Span::styled(path, Style::default().fg(Palette::SKY)),
1091
1091
+
]),
1092
1092
+
Line::from(Span::styled(
1093
1093
+
" All settings are also CLI flags — `fin --help`.",
1094
1094
+
muted_style(),
1095
1095
+
)),
1096
1096
+
];
1097
1097
+
let block = neon_block(" ⚙ Settings ", false);
1098
1098
+
let inner = block.inner(rows[0]);
1099
1099
+
f.render_widget(block, rows[0]);
1100
1100
+
f.render_widget(
1101
1101
+
Paragraph::new(lines).wrap(Wrap { trim: false }),
1102
1102
+
inner.inner(Margin::new(2, 0)),
1103
1103
+
);
1104
1104
+
1105
1105
+
// Bottom card — interactive server list. Enter switches, `t` cycles.
1106
1106
+
let current = cfg_snapshot.current_server.clone().unwrap_or_default();
1107
1107
+
let items: Vec<ListItem> = cfg_snapshot
1108
1108
+
.servers
1109
1109
+
.iter()
1110
1110
+
.map(|s| {
1111
1111
+
let is_current = s.name == current;
1112
1112
+
let marker = if is_current { " ▍ " } else { " " };
1113
1113
+
let name_style = if is_current {
1114
1114
+
Style::default()
1115
1115
+
.fg(Palette::PRIMARY)
1116
1116
+
.add_modifier(Modifier::BOLD)
1117
1117
+
} else {
1118
1118
+
Style::default().fg(Palette::FG)
1119
1119
+
};
1120
1120
+
ListItem::new(Line::from(vec![
1121
1121
+
Span::styled(
1122
1122
+
marker,
1123
1123
+
Style::default()
1124
1124
+
.fg(Palette::PRIMARY)
1125
1125
+
.add_modifier(Modifier::BOLD),
1126
1126
+
),
1127
1127
+
Span::styled(format!("{:<18}", s.name), name_style),
1128
1128
+
Span::styled(format!(" {} ", s.url), Style::default().fg(Palette::SKY)),
1129
1129
+
Span::styled(
1130
1130
+
format!("as {}", s.user_name),
1131
1131
+
Style::default().fg(Palette::MUTED),
1132
1132
+
),
1133
1133
+
]))
1134
1134
+
})
1135
1135
+
.collect();
1136
1136
+
1137
1137
+
let title = format!(
1138
1138
+
" ◉ Servers ({} saved — Enter to switch, t to cycle) ",
1139
1139
+
cfg_snapshot.servers.len()
1140
1140
+
);
1141
1141
+
1142
1142
+
if items.is_empty() {
1143
1143
+
let block = neon_block(&title, true);
1144
1144
+
let inner = block.inner(rows[1]);
1145
1145
+
f.render_widget(block, rows[1]);
1146
1146
+
f.render_widget(
1147
1147
+
Paragraph::new(Line::from(Span::styled(
1148
1148
+
" No servers yet — run `fin login <url>` to add one.",
1149
1149
+
muted_style(),
1150
1150
+
))),
1151
1151
+
inner.inner(Margin::new(2, 1)),
1152
1152
+
);
1153
1153
+
return;
1154
1154
+
}
1155
1155
+
1156
1156
+
let list = List::new(items)
1157
1157
+
.block(neon_block(&title, true))
1158
1158
+
.highlight_style(
1159
1159
+
Style::default()
1160
1160
+
.bg(Palette::SURFACE)
1161
1161
+
.fg(Palette::PRIMARY)
1162
1162
+
.add_modifier(Modifier::BOLD),
1163
1163
+
)
1164
1164
+
.highlight_symbol("");
1165
1165
+
f.render_stateful_widget(list, rows[1], &mut app.list_state);
1166
1166
+
}
1167
1167
+
1168
1168
+
fn draw_player_bar(f: &mut Frame<'_>, area: Rect, app: &App) {
1169
1169
+
let renderer_kind = *app.renderer_kind.lock();
1170
1170
+
let label = app.renderer_label.lock().clone();
1171
1171
+
let state = app.playback_state.lock().clone();
1172
1172
+
f.render_widget(
1173
1173
+
PlayerBar {
1174
1174
+
state: &state,
1175
1175
+
renderer: renderer_kind,
1176
1176
+
renderer_label: &label,
1177
1177
+
},
1178
1178
+
area,
1179
1179
+
);
1180
1180
+
}
1181
1181
+
1182
1182
+
fn draw_status_bar(f: &mut Frame<'_>, area: Rect, app: &App) {
1183
1183
+
let msg = app.status_message.lock().clone();
1184
1184
+
let help = " tab: screen ↑↓/jk: nav enter: play/switch a: queue n: next space: pause s: stop </>: skip +/-: vol m: mpv t: next server /: search q: quit ";
1185
1185
+
let text = msg.unwrap_or_else(|| help.to_string());
1186
1186
+
f.render_widget(
1187
1187
+
Paragraph::new(Line::from(Span::styled(
1188
1188
+
format!(" {} ", text),
1189
1189
+
Style::default().fg(Palette::MUTED),
1190
1190
+
)))
1191
1191
+
.style(Style::default().bg(Palette::SURFACE)),
1192
1192
+
area,
1193
1193
+
);
1194
1194
+
}
1195
1195
+
1196
1196
+
impl App {
1197
1197
+
pub fn config_snapshot(&self) -> Config {
1198
1198
+
self.config.lock().clone()
1199
1199
+
}
1200
1200
+
pub fn save_config(&self) -> Result<()> {
1201
1201
+
self.config.lock().save().context("saving config")
1202
1202
+
}
1203
1203
+
}
···
1
1
+
use std::time::Duration;
2
2
+
3
3
+
use anyhow::Result;
4
4
+
use crossterm::event::{Event as CtEvent, EventStream, KeyEvent, KeyEventKind};
5
5
+
use futures::StreamExt;
6
6
+
use tokio::sync::mpsc;
7
7
+
use tokio::time::interval;
8
8
+
9
9
+
/// Application-level events driving the render loop.
10
10
+
#[derive(Debug, Clone)]
11
11
+
pub enum AppEvent {
12
12
+
Key(KeyEvent),
13
13
+
Tick,
14
14
+
Resize(u16, u16),
15
15
+
}
16
16
+
17
17
+
/// Spawn a task that muxes crossterm input events and a periodic tick.
18
18
+
pub fn spawn_event_loop(tick: Duration) -> mpsc::UnboundedReceiver<AppEvent> {
19
19
+
let (tx, rx) = mpsc::unbounded_channel();
20
20
+
let tx_tick = tx.clone();
21
21
+
tokio::spawn(async move {
22
22
+
let mut ticker = interval(tick);
23
23
+
loop {
24
24
+
ticker.tick().await;
25
25
+
if tx_tick.send(AppEvent::Tick).is_err() {
26
26
+
break;
27
27
+
}
28
28
+
}
29
29
+
});
30
30
+
tokio::spawn(async move {
31
31
+
let mut events = EventStream::new();
32
32
+
while let Some(Ok(ev)) = events.next().await {
33
33
+
let out = match ev {
34
34
+
CtEvent::Key(k) if matches!(k.kind, KeyEventKind::Press | KeyEventKind::Repeat) => {
35
35
+
Some(AppEvent::Key(k))
36
36
+
}
37
37
+
CtEvent::Resize(w, h) => Some(AppEvent::Resize(w, h)),
38
38
+
_ => None,
39
39
+
};
40
40
+
if let Some(e) = out {
41
41
+
if tx.send(e).is_err() {
42
42
+
break;
43
43
+
}
44
44
+
}
45
45
+
}
46
46
+
});
47
47
+
rx
48
48
+
}
49
49
+
50
50
+
pub async fn drain_all(rx: &mut mpsc::UnboundedReceiver<AppEvent>) -> Result<Vec<AppEvent>> {
51
51
+
let mut out = Vec::new();
52
52
+
while let Ok(ev) = rx.try_recv() {
53
53
+
out.push(ev);
54
54
+
}
55
55
+
Ok(out)
56
56
+
}
···
1
1
+
pub mod app;
2
2
+
pub mod event;
3
3
+
pub mod screens;
4
4
+
pub mod theme;
5
5
+
pub mod widgets;
6
6
+
7
7
+
pub use app::{run_tui, App};
···
1
1
+
use ratatui::style::{Modifier, Style};
2
2
+
use ratatui::text::{Line, Span};
3
3
+
4
4
+
use fin_jellyfin::BaseItem;
5
5
+
6
6
+
use crate::theme::Palette;
7
7
+
8
8
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9
9
+
pub enum Screen {
10
10
+
Home,
11
11
+
Search,
12
12
+
Music,
13
13
+
Videos,
14
14
+
Playlists,
15
15
+
Queue,
16
16
+
Devices,
17
17
+
Settings,
18
18
+
}
19
19
+
20
20
+
impl Screen {
21
21
+
pub const ALL: &'static [Screen] = &[
22
22
+
Screen::Home,
23
23
+
Screen::Search,
24
24
+
Screen::Music,
25
25
+
Screen::Videos,
26
26
+
Screen::Playlists,
27
27
+
Screen::Queue,
28
28
+
Screen::Devices,
29
29
+
Screen::Settings,
30
30
+
];
31
31
+
32
32
+
pub fn icon(&self) -> &'static str {
33
33
+
match self {
34
34
+
Screen::Home => "◉",
35
35
+
Screen::Search => "⌕",
36
36
+
Screen::Music => "♪",
37
37
+
Screen::Videos => "▶",
38
38
+
Screen::Playlists => "▤",
39
39
+
Screen::Queue => "≡",
40
40
+
Screen::Devices => "◈",
41
41
+
Screen::Settings => "⚙",
42
42
+
}
43
43
+
}
44
44
+
45
45
+
pub fn label(&self) -> &'static str {
46
46
+
match self {
47
47
+
Screen::Home => "Home",
48
48
+
Screen::Search => "Search",
49
49
+
Screen::Music => "Music",
50
50
+
Screen::Videos => "Videos",
51
51
+
Screen::Playlists => "Playlists",
52
52
+
Screen::Queue => "Queue",
53
53
+
Screen::Devices => "Devices",
54
54
+
Screen::Settings => "Settings",
55
55
+
}
56
56
+
}
57
57
+
58
58
+
pub fn next(&self) -> Self {
59
59
+
let i = Self::ALL.iter().position(|s| s == self).unwrap_or(0);
60
60
+
Self::ALL[(i + 1) % Self::ALL.len()]
61
61
+
}
62
62
+
63
63
+
pub fn prev(&self) -> Self {
64
64
+
let i = Self::ALL.iter().position(|s| s == self).unwrap_or(0);
65
65
+
Self::ALL[(i + Self::ALL.len() - 1) % Self::ALL.len()]
66
66
+
}
67
67
+
}
68
68
+
69
69
+
pub fn item_row_line<'a>(item: &'a BaseItem, selected: bool) -> Line<'a> {
70
70
+
let icon = item.kind().icon();
71
71
+
let (icon_col, main_style) = if selected {
72
72
+
(
73
73
+
Palette::PRIMARY,
74
74
+
Style::default()
75
75
+
.fg(Palette::FG)
76
76
+
.add_modifier(Modifier::BOLD),
77
77
+
)
78
78
+
} else {
79
79
+
(Palette::ACCENT, Style::default().fg(Palette::FG))
80
80
+
};
81
81
+
let sub = item.subtitle();
82
82
+
let mut spans: Vec<Span<'a>> = vec![
83
83
+
Span::styled(
84
84
+
format!(" {} ", icon),
85
85
+
Style::default().fg(icon_col).add_modifier(Modifier::BOLD),
86
86
+
),
87
87
+
Span::styled(item.name.clone(), main_style),
88
88
+
];
89
89
+
if !sub.is_empty() {
90
90
+
spans.push(Span::styled(
91
91
+
format!(" {}", sub),
92
92
+
Style::default().fg(Palette::MUTED),
93
93
+
));
94
94
+
}
95
95
+
if let Some(d) = item.duration_secs() {
96
96
+
let mm = d / 60;
97
97
+
let ss = d % 60;
98
98
+
spans.push(Span::styled(
99
99
+
format!(" {}:{:02}", mm, ss),
100
100
+
Style::default().fg(Palette::SKY),
101
101
+
));
102
102
+
}
103
103
+
Line::from(spans)
104
104
+
}
···
1
1
+
use ratatui::style::{Color, Modifier, Style};
2
2
+
3
3
+
/// Neon-electric palette — matches the pocketenv CLI theme.
4
4
+
pub struct Palette;
5
5
+
6
6
+
impl Palette {
7
7
+
/// Primary accent — electric teal.
8
8
+
pub const PRIMARY: Color = Color::Rgb(0, 232, 198);
9
9
+
/// Secondary accent — bright cyan.
10
10
+
pub const SECONDARY: Color = Color::Rgb(0, 198, 232);
11
11
+
/// Deep violet neon accent used for selection.
12
12
+
pub const ACCENT: Color = Color::Rgb(130, 100, 255);
13
13
+
/// Vivid mint highlight.
14
14
+
pub const HIGHLIGHT: Color = Color::Rgb(100, 232, 130);
15
15
+
/// Muted foreground for secondary text.
16
16
+
pub const MUTED: Color = Color::Rgb(160, 175, 195);
17
17
+
/// Playful orange used for links / stream indicators.
18
18
+
pub const LINK: Color = Color::Rgb(255, 160, 100);
19
19
+
/// Sky blue.
20
20
+
pub const SKY: Color = Color::Rgb(0, 210, 255);
21
21
+
/// Neon pink used for warnings.
22
22
+
pub const WARN: Color = Color::Rgb(255, 110, 220);
23
23
+
/// Vivid red for errors.
24
24
+
pub const ERROR: Color = Color::Rgb(255, 100, 100);
25
25
+
/// Off-black background.
26
26
+
pub const BG: Color = Color::Rgb(6, 8, 18);
27
27
+
/// Slightly lighter surface for cards.
28
28
+
pub const SURFACE: Color = Color::Rgb(12, 16, 30);
29
29
+
/// Foreground text.
30
30
+
pub const FG: Color = Color::Rgb(224, 235, 255);
31
31
+
}
32
32
+
33
33
+
pub fn base_style() -> Style {
34
34
+
Style::default().fg(Palette::FG).bg(Palette::BG)
35
35
+
}
36
36
+
37
37
+
pub fn title_style() -> Style {
38
38
+
Style::default()
39
39
+
.fg(Palette::PRIMARY)
40
40
+
.add_modifier(Modifier::BOLD)
41
41
+
}
42
42
+
43
43
+
pub fn muted_style() -> Style {
44
44
+
Style::default().fg(Palette::MUTED)
45
45
+
}
46
46
+
47
47
+
pub fn accent_style() -> Style {
48
48
+
Style::default()
49
49
+
.fg(Palette::ACCENT)
50
50
+
.add_modifier(Modifier::BOLD)
51
51
+
}
52
52
+
53
53
+
pub fn highlight_style() -> Style {
54
54
+
Style::default()
55
55
+
.fg(Palette::BG)
56
56
+
.bg(Palette::PRIMARY)
57
57
+
.add_modifier(Modifier::BOLD)
58
58
+
}
59
59
+
60
60
+
pub fn selection_style() -> Style {
61
61
+
Style::default()
62
62
+
.fg(Palette::ACCENT)
63
63
+
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
64
64
+
}
65
65
+
66
66
+
pub fn tab_active() -> Style {
67
67
+
Style::default()
68
68
+
.fg(Palette::PRIMARY)
69
69
+
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
70
70
+
}
71
71
+
72
72
+
pub fn tab_inactive() -> Style {
73
73
+
Style::default().fg(Palette::MUTED)
74
74
+
}
75
75
+
76
76
+
pub fn border_style(active: bool) -> Style {
77
77
+
if active {
78
78
+
Style::default()
79
79
+
.fg(Palette::PRIMARY)
80
80
+
.add_modifier(Modifier::BOLD)
81
81
+
} else {
82
82
+
Style::default().fg(Palette::MUTED)
83
83
+
}
84
84
+
}
85
85
+
86
86
+
pub fn status_playing() -> Style {
87
87
+
Style::default()
88
88
+
.fg(Palette::HIGHLIGHT)
89
89
+
.add_modifier(Modifier::BOLD)
90
90
+
}
91
91
+
92
92
+
pub fn status_paused() -> Style {
93
93
+
Style::default().fg(Palette::WARN)
94
94
+
}
···
1
1
+
pub mod player_bar;
2
2
+
pub mod tabs;
3
3
+
4
4
+
pub use player_bar::PlayerBar;
5
5
+
pub use tabs::NeonTabs;
6
6
+
7
7
+
use ratatui::style::Style;
8
8
+
use ratatui::symbols::border;
9
9
+
use ratatui::widgets::{Block, Borders};
10
10
+
11
11
+
use crate::theme::{border_style, title_style, Palette};
12
12
+
13
13
+
/// Consistent rounded neon block used across screens.
14
14
+
pub fn neon_block(title: &str, active: bool) -> Block<'_> {
15
15
+
Block::default()
16
16
+
.borders(Borders::ALL)
17
17
+
.border_set(border::ROUNDED)
18
18
+
.border_style(border_style(active))
19
19
+
.title_style(title_style())
20
20
+
.style(Style::default().bg(Palette::BG))
21
21
+
.title(title.to_string())
22
22
+
}
···
1
1
+
use ratatui::buffer::Buffer;
2
2
+
use ratatui::layout::{Constraint, Direction, Layout, Rect};
3
3
+
use ratatui::style::{Modifier, Style};
4
4
+
use ratatui::symbols::border;
5
5
+
use ratatui::text::{Line, Span};
6
6
+
use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Widget};
7
7
+
8
8
+
use fin_player::{PlaybackState, PlaybackStatus, RendererKind};
9
9
+
10
10
+
use crate::theme::{border_style, muted_style, title_style, Palette};
11
11
+
12
12
+
pub struct PlayerBar<'a> {
13
13
+
pub state: &'a PlaybackState,
14
14
+
pub renderer: RendererKind,
15
15
+
pub renderer_label: &'a str,
16
16
+
}
17
17
+
18
18
+
fn fmt_time(secs: f64) -> String {
19
19
+
if !secs.is_finite() || secs < 0.0 {
20
20
+
return "0:00".into();
21
21
+
}
22
22
+
let s = secs as u64;
23
23
+
let (h, rem) = (s / 3600, s % 3600);
24
24
+
let (m, sec) = (rem / 60, rem % 60);
25
25
+
if h > 0 {
26
26
+
format!("{}:{:02}:{:02}", h, m, sec)
27
27
+
} else {
28
28
+
format!("{}:{:02}", m, sec)
29
29
+
}
30
30
+
}
31
31
+
32
32
+
fn status_icon(status: PlaybackStatus) -> (&'static str, Style) {
33
33
+
match status {
34
34
+
PlaybackStatus::Playing => (
35
35
+
"▶",
36
36
+
Style::default()
37
37
+
.fg(Palette::HIGHLIGHT)
38
38
+
.add_modifier(Modifier::BOLD),
39
39
+
),
40
40
+
PlaybackStatus::Paused => (
41
41
+
"⏸",
42
42
+
Style::default()
43
43
+
.fg(Palette::WARN)
44
44
+
.add_modifier(Modifier::BOLD),
45
45
+
),
46
46
+
PlaybackStatus::Buffering => (
47
47
+
"⋯",
48
48
+
Style::default()
49
49
+
.fg(Palette::SKY)
50
50
+
.add_modifier(Modifier::BOLD),
51
51
+
),
52
52
+
PlaybackStatus::Stopped | PlaybackStatus::Idle => (
53
53
+
"⏹",
54
54
+
Style::default()
55
55
+
.fg(Palette::MUTED)
56
56
+
.add_modifier(Modifier::BOLD),
57
57
+
),
58
58
+
}
59
59
+
}
60
60
+
61
61
+
impl<'a> Widget for PlayerBar<'a> {
62
62
+
fn render(self, area: Rect, buf: &mut Buffer) {
63
63
+
let block = Block::default()
64
64
+
.borders(Borders::ALL)
65
65
+
.border_set(border::ROUNDED)
66
66
+
.border_style(border_style(true))
67
67
+
.title_style(title_style())
68
68
+
.style(Style::default().bg(Palette::BG))
69
69
+
.title(" ⚡ Now Playing ");
70
70
+
let inner = block.inner(area);
71
71
+
block.render(area, buf);
72
72
+
73
73
+
let rows = Layout::default()
74
74
+
.direction(Direction::Vertical)
75
75
+
.constraints([
76
76
+
Constraint::Length(1),
77
77
+
Constraint::Length(1),
78
78
+
Constraint::Min(1),
79
79
+
])
80
80
+
.split(inner);
81
81
+
82
82
+
let (icon, icon_style) = status_icon(self.state.status);
83
83
+
let renderer_icon = match self.renderer {
84
84
+
RendererKind::Mpv => " mpv",
85
85
+
RendererKind::Chromecast => " chromecast",
86
86
+
};
87
87
+
let (title_text, subtitle_text) = match &self.state.now_playing {
88
88
+
Some(item) => (item.title.clone(), item.subtitle.clone()),
89
89
+
None => ("Nothing playing".to_string(), String::new()),
90
90
+
};
91
91
+
92
92
+
// Row 1: status icon + track title + right-aligned renderer + volume
93
93
+
let title_line = Line::from(vec![
94
94
+
Span::styled(format!("{} ", icon), icon_style),
95
95
+
Span::styled(
96
96
+
title_text,
97
97
+
Style::default()
98
98
+
.fg(Palette::FG)
99
99
+
.add_modifier(Modifier::BOLD),
100
100
+
),
101
101
+
]);
102
102
+
let right_line = Line::from(vec![
103
103
+
Span::styled(
104
104
+
format!("♪ {}% ", (self.state.volume * 100.0) as i32),
105
105
+
Style::default().fg(Palette::HIGHLIGHT),
106
106
+
),
107
107
+
Span::styled(
108
108
+
format!("{} ", renderer_icon),
109
109
+
Style::default()
110
110
+
.fg(Palette::ACCENT)
111
111
+
.add_modifier(Modifier::BOLD),
112
112
+
),
113
113
+
Span::styled(
114
114
+
format!("on {}", self.renderer_label),
115
115
+
Style::default().fg(Palette::MUTED),
116
116
+
),
117
117
+
]);
118
118
+
let cols = Layout::default()
119
119
+
.direction(Direction::Horizontal)
120
120
+
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
121
121
+
.split(rows[0]);
122
122
+
Paragraph::new(title_line).render(cols[0], buf);
123
123
+
Paragraph::new(right_line)
124
124
+
.alignment(ratatui::layout::Alignment::Right)
125
125
+
.render(cols[1], buf);
126
126
+
127
127
+
// Row 2: subtitle
128
128
+
let sub_line = Line::from(vec![Span::styled(subtitle_text, muted_style())]);
129
129
+
Paragraph::new(sub_line).render(rows[1], buf);
130
130
+
131
131
+
// Row 3: progress bar with elapsed/total on either side
132
132
+
let cols2 = Layout::default()
133
133
+
.direction(Direction::Horizontal)
134
134
+
.constraints([
135
135
+
Constraint::Length(8),
136
136
+
Constraint::Min(10),
137
137
+
Constraint::Length(8),
138
138
+
])
139
139
+
.split(rows[2]);
140
140
+
let elapsed = fmt_time(self.state.position_secs);
141
141
+
let total = if self.state.duration_secs > 0.0 {
142
142
+
fmt_time(self.state.duration_secs)
143
143
+
} else if let Some(item) = &self.state.now_playing {
144
144
+
item.duration_secs
145
145
+
.map(|s| fmt_time(s as f64))
146
146
+
.unwrap_or_else(|| "--:--".into())
147
147
+
} else {
148
148
+
"--:--".into()
149
149
+
};
150
150
+
Paragraph::new(Line::from(Span::styled(
151
151
+
elapsed,
152
152
+
Style::default().fg(Palette::SKY),
153
153
+
)))
154
154
+
.render(cols2[0], buf);
155
155
+
let ratio = if self.state.duration_secs > 0.0 {
156
156
+
(self.state.position_secs / self.state.duration_secs).clamp(0.0, 1.0)
157
157
+
} else {
158
158
+
0.0
159
159
+
};
160
160
+
Gauge::default()
161
161
+
.gauge_style(
162
162
+
Style::default()
163
163
+
.fg(Palette::PRIMARY)
164
164
+
.bg(Palette::SURFACE)
165
165
+
.add_modifier(Modifier::BOLD),
166
166
+
)
167
167
+
.ratio(ratio)
168
168
+
.label("")
169
169
+
.use_unicode(true)
170
170
+
.render(cols2[1], buf);
171
171
+
Paragraph::new(Line::from(Span::styled(
172
172
+
total,
173
173
+
Style::default().fg(Palette::SKY),
174
174
+
)))
175
175
+
.alignment(ratatui::layout::Alignment::Right)
176
176
+
.render(cols2[2], buf);
177
177
+
}
178
178
+
}
···
1
1
+
use ratatui::buffer::Buffer;
2
2
+
use ratatui::layout::Rect;
3
3
+
use ratatui::style::{Color, Modifier, Style};
4
4
+
use ratatui::text::{Line, Span};
5
5
+
use ratatui::widgets::Widget;
6
6
+
7
7
+
use crate::theme::Palette;
8
8
+
9
9
+
pub struct NeonTabs<'a> {
10
10
+
labels: &'a [(&'a str, &'a str)], // (icon, label)
11
11
+
selected: usize,
12
12
+
}
13
13
+
14
14
+
impl<'a> NeonTabs<'a> {
15
15
+
pub fn new(labels: &'a [(&'a str, &'a str)], selected: usize) -> Self {
16
16
+
Self { labels, selected }
17
17
+
}
18
18
+
}
19
19
+
20
20
+
impl<'a> Widget for NeonTabs<'a> {
21
21
+
fn render(self, area: Rect, buf: &mut Buffer) {
22
22
+
let mut spans: Vec<Span<'_>> = Vec::new();
23
23
+
for (i, (icon, label)) in self.labels.iter().enumerate() {
24
24
+
let is_sel = i == self.selected;
25
25
+
let bracket = if is_sel { "▍" } else { " " };
26
26
+
let bracket_col = if is_sel {
27
27
+
Palette::PRIMARY
28
28
+
} else {
29
29
+
Palette::BG
30
30
+
};
31
31
+
spans.push(Span::styled(bracket, Style::default().fg(bracket_col)));
32
32
+
let (icon_col, text_style) = if is_sel {
33
33
+
(
34
34
+
Palette::PRIMARY,
35
35
+
Style::default()
36
36
+
.fg(Palette::FG)
37
37
+
.add_modifier(Modifier::BOLD),
38
38
+
)
39
39
+
} else {
40
40
+
(Palette::MUTED, Style::default().fg(Palette::MUTED))
41
41
+
};
42
42
+
spans.push(Span::styled(
43
43
+
format!(" {} ", icon),
44
44
+
Style::default().fg(icon_col).add_modifier(Modifier::BOLD),
45
45
+
));
46
46
+
spans.push(Span::styled(format!("{} ", label), text_style));
47
47
+
if i + 1 < self.labels.len() {
48
48
+
spans.push(Span::styled(
49
49
+
"│ ",
50
50
+
Style::default().fg(Color::Rgb(30, 40, 60)),
51
51
+
));
52
52
+
}
53
53
+
}
54
54
+
Line::from(spans).render(area, buf);
55
55
+
}
56
56
+
}
···
1
1
+
[package]
2
2
+
name = "fin"
3
3
+
version.workspace = true
4
4
+
edition.workspace = true
5
5
+
authors.workspace = true
6
6
+
license.workspace = true
7
7
+
repository.workspace = true
8
8
+
description = "A neon-electric TUI Jellyfin client for mpv & Chromecast."
9
9
+
10
10
+
[[bin]]
11
11
+
name = "fin"
12
12
+
path = "src/main.rs"
13
13
+
14
14
+
[dependencies]
15
15
+
anyhow.workspace = true
16
16
+
clap.workspace = true
17
17
+
tokio.workspace = true
18
18
+
serde.workspace = true
19
19
+
serde_json.workspace = true
20
20
+
tracing.workspace = true
21
21
+
tracing-subscriber.workspace = true
22
22
+
directories.workspace = true
23
23
+
toml.workspace = true
24
24
+
rpassword.workspace = true
25
25
+
fin-config.workspace = true
26
26
+
fin-jellyfin.workspace = true
27
27
+
fin-player.workspace = true
28
28
+
fin-tui.workspace = true
···
1
1
+
use clap::builder::styling::{AnsiColor, Color, RgbColor, Style, Styles};
2
2
+
use clap::{Args, Parser, Subcommand, ValueEnum};
3
3
+
use fin_config::RendererPref;
4
4
+
5
5
+
/// Neon-electric palette applied to clap's help & error output.
6
6
+
/// Mirrors the ratatui theme in `fin_tui::theme`.
7
7
+
pub fn neon_styles() -> Styles {
8
8
+
let primary = Color::Rgb(RgbColor(0, 232, 198)); // electric teal — section titles
9
9
+
let sky = Color::Rgb(RgbColor(0, 210, 255)); // sky blue — flag literals
10
10
+
let accent = Color::Rgb(RgbColor(130, 100, 255)); // violet — "Usage:" line
11
11
+
let highlight = Color::Rgb(RgbColor(100, 232, 130)); // mint — valid values
12
12
+
let link = Color::Rgb(RgbColor(255, 160, 100)); // orange — <PLACEHOLDER> tokens
13
13
+
let error = Color::Rgb(RgbColor(255, 100, 100)); // red — "error:" prefix
14
14
+
15
15
+
Styles::styled()
16
16
+
.header(Style::new().bold().underline().fg_color(Some(primary)))
17
17
+
.usage(Style::new().bold().fg_color(Some(accent)))
18
18
+
.literal(Style::new().bold().fg_color(Some(sky)))
19
19
+
.placeholder(Style::new().fg_color(Some(link)))
20
20
+
.valid(Style::new().bold().fg_color(Some(highlight)))
21
21
+
.invalid(
22
22
+
Style::new()
23
23
+
.bold()
24
24
+
.fg_color(Some(Color::Ansi(AnsiColor::Yellow))),
25
25
+
)
26
26
+
.error(Style::new().bold().fg_color(Some(error)))
27
27
+
}
28
28
+
29
29
+
/// A clap-friendly mirror of `fin_config::RendererPref`. Kept out of
30
30
+
/// `fin-config` so that library doesn't pull in clap.
31
31
+
#[derive(Debug, Clone, Copy, ValueEnum)]
32
32
+
pub enum RendererArg {
33
33
+
Mpv,
34
34
+
Chromecast,
35
35
+
}
36
36
+
37
37
+
impl From<RendererArg> for RendererPref {
38
38
+
fn from(a: RendererArg) -> Self {
39
39
+
match a {
40
40
+
RendererArg::Mpv => RendererPref::Mpv,
41
41
+
RendererArg::Chromecast => RendererPref::Chromecast,
42
42
+
}
43
43
+
}
44
44
+
}
45
45
+
46
46
+
/// fin — a neon-electric TUI Jellyfin client for mpv & Chromecast.
47
47
+
///
48
48
+
/// Every setting is exposed as both a CLI flag and a TOML key:
49
49
+
///
50
50
+
/// fin --server https://media.example --renderer chromecast --chromecast "Living Room"
51
51
+
///
52
52
+
/// or persist the equivalent settings in the config file (see `fin config --path`).
53
53
+
#[derive(Debug, Parser, Clone)]
54
54
+
#[command(
55
55
+
author,
56
56
+
version,
57
57
+
about,
58
58
+
long_about,
59
59
+
propagate_version = true,
60
60
+
arg_required_else_help = false,
61
61
+
styles = neon_styles(),
62
62
+
)]
63
63
+
pub struct Cli {
64
64
+
/// Increase log verbosity (-v, -vv).
65
65
+
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
66
66
+
pub verbose: u8,
67
67
+
68
68
+
/// Jellyfin server URL. Overrides the TOML `server.url`.
69
69
+
#[arg(long, global = true, env = "FIN_SERVER")]
70
70
+
pub server: Option<String>,
71
71
+
72
72
+
/// Use a saved server by its name (see `fin server`). Overrides `current_server`.
73
73
+
#[arg(long = "server-name", global = true, env = "FIN_SERVER_NAME")]
74
74
+
pub server_name: Option<String>,
75
75
+
76
76
+
/// Access token (skip `login` if you already have one). Overrides `server.access_token`.
77
77
+
#[arg(long, global = true, env = "FIN_TOKEN")]
78
78
+
pub token: Option<String>,
79
79
+
80
80
+
/// User id. Overrides `server.user_id`.
81
81
+
#[arg(long, global = true, env = "FIN_USER_ID")]
82
82
+
pub user_id: Option<String>,
83
83
+
84
84
+
/// User name (display only). Overrides `server.user_name`.
85
85
+
#[arg(long, global = true)]
86
86
+
pub user_name: Option<String>,
87
87
+
88
88
+
/// Client device id. Overrides `server.device_id`.
89
89
+
#[arg(long, global = true, env = "FIN_DEVICE_ID")]
90
90
+
pub device_id: Option<String>,
91
91
+
92
92
+
/// Renderer to use. Advanced form; usually `--mpv` / `--chromecast NAME` is enough.
93
93
+
#[arg(long, global = true, value_enum, env = "FIN_RENDERER")]
94
94
+
pub renderer: Option<RendererArg>,
95
95
+
96
96
+
/// Force the local mpv renderer.
97
97
+
#[arg(long, global = true, conflicts_with_all = ["chromecast", "renderer"])]
98
98
+
pub mpv: bool,
99
99
+
100
100
+
/// Cast to a Chromecast by display name. Implies `--renderer chromecast`.
101
101
+
/// Pass an empty string to auto-pick the first device found.
102
102
+
#[arg(long, global = true, env = "FIN_CHROMECAST", num_args = 0..=1, default_missing_value = "")]
103
103
+
pub chromecast: Option<String>,
104
104
+
105
105
+
#[command(subcommand)]
106
106
+
pub command: Option<Command>,
107
107
+
}
108
108
+
109
109
+
#[derive(Debug, Subcommand, Clone)]
110
110
+
pub enum Command {
111
111
+
/// (default) launch the neon TUI.
112
112
+
Tui,
113
113
+
114
114
+
/// Authenticate against a Jellyfin server and save credentials.
115
115
+
/// Servers are stored by name — pass `--name` to keep more than one.
116
116
+
Login {
117
117
+
/// Server URL — e.g. https://media.example
118
118
+
url: String,
119
119
+
/// Username (prompted if omitted).
120
120
+
#[arg(long, short = 'u')]
121
121
+
username: Option<String>,
122
122
+
/// Password (prompted if omitted, hidden while typing).
123
123
+
#[arg(long, short = 'p')]
124
124
+
password: Option<String>,
125
125
+
/// Short name for this server. Defaults to the URL host
126
126
+
/// (e.g. `media.example.com`). Reusing an existing name updates it.
127
127
+
#[arg(long, short = 'n')]
128
128
+
name: Option<String>,
129
129
+
},
130
130
+
131
131
+
/// Remove a saved server. If no `--name` is given, the current server is removed.
132
132
+
Logout {
133
133
+
#[arg(long, short = 'n')]
134
134
+
name: Option<String>,
135
135
+
},
136
136
+
137
137
+
/// Manage saved Jellyfin servers.
138
138
+
#[command(subcommand)]
139
139
+
Server(ServerCmd),
140
140
+
141
141
+
/// Search the server from the shell.
142
142
+
Search {
143
143
+
/// Query string.
144
144
+
query: String,
145
145
+
/// Restrict to a kind: audio | music | video | movies (default: all).
146
146
+
#[arg(long, short = 'k')]
147
147
+
kind: Option<String>,
148
148
+
/// Max results.
149
149
+
#[arg(long, default_value_t = 25)]
150
150
+
limit: u32,
151
151
+
},
152
152
+
153
153
+
/// Search + play the first hit on the configured renderer.
154
154
+
Play(PlayArgs),
155
155
+
156
156
+
/// Search + append to the current renderer queue.
157
157
+
Queue(PlayArgs),
158
158
+
159
159
+
/// List Chromecasts on the local network.
160
160
+
Devices {
161
161
+
/// mDNS scan duration in seconds.
162
162
+
#[arg(long, default_value_t = 4)]
163
163
+
scan_seconds: u64,
164
164
+
},
165
165
+
166
166
+
/// List playlists, or dump one when given `--list <id>`.
167
167
+
Playlists {
168
168
+
/// Playlist id to dump.
169
169
+
#[arg(long)]
170
170
+
list: Option<String>,
171
171
+
},
172
172
+
173
173
+
/// Inspect or locate the config file.
174
174
+
Config {
175
175
+
/// Print the current config.
176
176
+
#[arg(long)]
177
177
+
show: bool,
178
178
+
/// Print the config file path.
179
179
+
#[arg(long)]
180
180
+
path: bool,
181
181
+
},
182
182
+
}
183
183
+
184
184
+
#[derive(Debug, Subcommand, Clone)]
185
185
+
pub enum ServerCmd {
186
186
+
/// List all saved servers. The current one is marked ▍.
187
187
+
#[command(alias = "ls")]
188
188
+
List,
189
189
+
/// Switch the active server by name.
190
190
+
Switch {
191
191
+
/// Server name (see `fin server list`).
192
192
+
name: String,
193
193
+
},
194
194
+
/// Remove a saved server.
195
195
+
#[command(alias = "remove")]
196
196
+
Rm {
197
197
+
/// Server name (see `fin server list`).
198
198
+
name: String,
199
199
+
},
200
200
+
/// Rename a saved server.
201
201
+
Rename {
202
202
+
/// Old name.
203
203
+
from: String,
204
204
+
/// New name.
205
205
+
to: String,
206
206
+
},
207
207
+
}
208
208
+
209
209
+
#[derive(Debug, Args, Clone)]
210
210
+
pub struct PlayArgs {
211
211
+
/// Search query. The first hit wins.
212
212
+
pub query: String,
213
213
+
/// Restrict to a kind: audio | music | video | movies.
214
214
+
#[arg(long, short = 'k')]
215
215
+
pub kind: Option<String>,
216
216
+
/// Force HLS transcoded stream (default for Chromecast).
217
217
+
#[arg(long)]
218
218
+
pub hls: bool,
219
219
+
}
220
220
+
221
221
+
// `Queue` reuses `PlayArgs` semantics. Introducing a distinct type keeps the
222
222
+
// derive-based subcommand parsing simple.
223
223
+
#[derive(Debug, Args, Clone)]
224
224
+
pub struct QueueArgs {
225
225
+
/// Search query. The first hit wins.
226
226
+
pub query: String,
227
227
+
/// Restrict to a kind: audio | music | video | movies.
228
228
+
#[arg(long, short = 'k')]
229
229
+
pub kind: Option<String>,
230
230
+
/// Force HLS transcoded stream (default for Chromecast).
231
231
+
#[arg(long)]
232
232
+
pub hls: bool,
233
233
+
}
234
234
+
235
235
+
impl From<PlayArgs> for QueueArgs {
236
236
+
fn from(p: PlayArgs) -> Self {
237
237
+
Self {
238
238
+
query: p.query,
239
239
+
kind: p.kind,
240
240
+
hls: p.hls,
241
241
+
}
242
242
+
}
243
243
+
}
···
1
1
+
mod cli;
2
2
+
mod preflight;
3
3
+
4
4
+
use std::io::{self, Write};
5
5
+
use std::sync::Arc;
6
6
+
use std::time::Duration;
7
7
+
8
8
+
use anyhow::{Context, Result};
9
9
+
use clap::Parser;
10
10
+
use fin_config::{derive_server_name, Config, RendererPref, ServerConfig};
11
11
+
use fin_jellyfin::{ItemKind, JellyfinClient, StreamFormat};
12
12
+
use fin_player::{
13
13
+
discover_chromecasts, CastDevice, ChromecastRenderer, MpvRenderer, QueueItem, Renderer,
14
14
+
RendererKind,
15
15
+
};
16
16
+
use fin_tui::{run_tui, App};
17
17
+
use tracing_subscriber::EnvFilter;
18
18
+
19
19
+
use crate::cli::{Cli, Command, PlayArgs, ServerCmd};
20
20
+
21
21
+
#[tokio::main]
22
22
+
async fn main() -> Result<()> {
23
23
+
let cli = Cli::parse();
24
24
+
init_tracing(cli.verbose);
25
25
+
26
26
+
// preflight: mpv must be installed no matter what renderer the user picks
27
27
+
preflight::ensure_mpv()?;
28
28
+
29
29
+
// Merge CLI flags into the on-disk config so any setting can be overridden inline.
30
30
+
let mut config = load_and_merge(&cli)?;
31
31
+
32
32
+
match cli.command.clone().unwrap_or(Command::Tui) {
33
33
+
Command::Tui => run_tui_cmd(config).await,
34
34
+
Command::Login {
35
35
+
url,
36
36
+
username,
37
37
+
password,
38
38
+
name,
39
39
+
} => cmd_login(&mut config, url, username, password, name).await,
40
40
+
Command::Logout { name } => cmd_logout(&mut config, name),
41
41
+
Command::Server(cmd) => cmd_server(&mut config, cmd),
42
42
+
Command::Search { query, kind, limit } => cmd_search(&config, &query, kind, limit).await,
43
43
+
Command::Play(args) => cmd_play(&config, args, false).await,
44
44
+
Command::Queue(args) => cmd_play(&config, args, true).await,
45
45
+
Command::Devices { scan_seconds } => cmd_devices(scan_seconds).await,
46
46
+
Command::Playlists { list } => cmd_playlists(&config, list).await,
47
47
+
Command::Config { show, path } => cmd_config(&config, show, path),
48
48
+
}
49
49
+
}
50
50
+
51
51
+
fn init_tracing(verbose: u8) {
52
52
+
let level = match verbose {
53
53
+
0 => "warn,fin=info",
54
54
+
1 => "info,fin=debug",
55
55
+
_ => "debug",
56
56
+
};
57
57
+
let _ = tracing_subscriber::fmt()
58
58
+
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| level.into()))
59
59
+
.with_target(false)
60
60
+
.with_writer(io::stderr)
61
61
+
.try_init();
62
62
+
}
63
63
+
64
64
+
fn load_and_merge(cli: &Cli) -> Result<Config> {
65
65
+
let mut cfg = Config::load()?;
66
66
+
67
67
+
// 1) Pick which server this invocation should use.
68
68
+
if let Some(name) = cli.server_name.as_deref() {
69
69
+
// Non-fatal: if the name doesn't exist yet, we let the actual
70
70
+
// command (e.g. `login`) create it.
71
71
+
if cfg.find_server(name).is_some() {
72
72
+
cfg.switch_to(name)?;
73
73
+
} else {
74
74
+
cfg.current_server = Some(name.to_string());
75
75
+
}
76
76
+
}
77
77
+
78
78
+
// 2) Layer inline flag overrides onto the *current* server, if any.
79
79
+
// `--server URL` on its own (no `--server-name`) points to an
80
80
+
// ephemeral server used for this run only, so we upsert one under
81
81
+
// the URL host as its name.
82
82
+
if let Some(url) = &cli.server {
83
83
+
let name = cli
84
84
+
.server_name
85
85
+
.clone()
86
86
+
.unwrap_or_else(|| derive_server_name(url));
87
87
+
let existing = cfg.find_server(&name).cloned();
88
88
+
let merged = ServerConfig {
89
89
+
name: name.clone(),
90
90
+
url: url.clone(),
91
91
+
user_id: cli
92
92
+
.user_id
93
93
+
.clone()
94
94
+
.or_else(|| existing.as_ref().map(|s| s.user_id.clone()))
95
95
+
.unwrap_or_default(),
96
96
+
user_name: cli
97
97
+
.user_name
98
98
+
.clone()
99
99
+
.or_else(|| existing.as_ref().map(|s| s.user_name.clone()))
100
100
+
.unwrap_or_default(),
101
101
+
access_token: cli
102
102
+
.token
103
103
+
.clone()
104
104
+
.or_else(|| existing.as_ref().map(|s| s.access_token.clone()))
105
105
+
.unwrap_or_default(),
106
106
+
device_id: cli
107
107
+
.device_id
108
108
+
.clone()
109
109
+
.or_else(|| existing.as_ref().map(|s| s.device_id.clone()))
110
110
+
.unwrap_or_default(),
111
111
+
};
112
112
+
cfg.add_or_update_server(merged);
113
113
+
} else {
114
114
+
// No URL override — still let per-field flags patch the current server.
115
115
+
if let Some(cur_name) = cfg.current_server.clone() {
116
116
+
if let Some(server) = cfg.servers.iter_mut().find(|s| s.name == cur_name) {
117
117
+
if let Some(t) = &cli.token {
118
118
+
server.access_token = t.clone();
119
119
+
}
120
120
+
if let Some(u) = &cli.user_id {
121
121
+
server.user_id = u.clone();
122
122
+
}
123
123
+
if let Some(u) = &cli.user_name {
124
124
+
server.user_name = u.clone();
125
125
+
}
126
126
+
if let Some(d) = &cli.device_id {
127
127
+
server.device_id = d.clone();
128
128
+
}
129
129
+
}
130
130
+
}
131
131
+
}
132
132
+
133
133
+
// Shortcut flags win over `--renderer` and the on-disk pref.
134
134
+
// Default (when nothing is set) → mpv.
135
135
+
if cli.mpv {
136
136
+
cfg.renderer = RendererPref::Mpv;
137
137
+
} else if let Some(name) = &cli.chromecast {
138
138
+
cfg.renderer = RendererPref::Chromecast;
139
139
+
if !name.is_empty() {
140
140
+
cfg.last_chromecast = Some(name.clone());
141
141
+
}
142
142
+
} else if let Some(r) = cli.renderer {
143
143
+
cfg.renderer = r.into();
144
144
+
}
145
145
+
Ok(cfg)
146
146
+
}
147
147
+
148
148
+
fn make_client(cfg: &Config) -> Result<JellyfinClient> {
149
149
+
let server = cfg.require_current()?;
150
150
+
JellyfinClient::with_credentials(
151
151
+
&server.url,
152
152
+
&server.device_id,
153
153
+
&server.user_id,
154
154
+
&server.access_token,
155
155
+
)
156
156
+
}
157
157
+
158
158
+
async fn build_renderer(cfg: &Config) -> Result<(Arc<dyn Renderer>, String)> {
159
159
+
match cfg.renderer {
160
160
+
RendererPref::Mpv => {
161
161
+
let r = MpvRenderer::new(None);
162
162
+
Ok((Arc::new(r), "this machine".to_string()))
163
163
+
}
164
164
+
RendererPref::Chromecast => {
165
165
+
// Try to reconnect to the previously used device, otherwise
166
166
+
// pick the first one we discover.
167
167
+
let devices = discover_chromecasts(Duration::from_secs(3)).await?;
168
168
+
let target = pick_chromecast(&devices, cfg.last_chromecast.as_deref())?;
169
169
+
let name = target.display_name();
170
170
+
let r = ChromecastRenderer::connect(target).await?;
171
171
+
Ok((Arc::new(r), name))
172
172
+
}
173
173
+
}
174
174
+
}
175
175
+
176
176
+
fn pick_chromecast(devices: &[CastDevice], preferred: Option<&str>) -> Result<CastDevice> {
177
177
+
if devices.is_empty() {
178
178
+
anyhow::bail!("no Chromecasts found on the local network");
179
179
+
}
180
180
+
if let Some(name) = preferred {
181
181
+
if let Some(d) = devices
182
182
+
.iter()
183
183
+
.find(|d| d.display_name().eq_ignore_ascii_case(name))
184
184
+
{
185
185
+
return Ok(d.clone());
186
186
+
}
187
187
+
}
188
188
+
Ok(devices[0].clone())
189
189
+
}
190
190
+
191
191
+
async fn run_tui_cmd(cfg: Config) -> Result<()> {
192
192
+
let client = make_client(&cfg)?;
193
193
+
let (renderer, _) = build_renderer(&cfg).await?;
194
194
+
let app = App::new(cfg, client, renderer);
195
195
+
run_tui(app).await
196
196
+
}
197
197
+
198
198
+
async fn cmd_login(
199
199
+
config: &mut Config,
200
200
+
url: String,
201
201
+
username: Option<String>,
202
202
+
password: Option<String>,
203
203
+
name: Option<String>,
204
204
+
) -> Result<()> {
205
205
+
let url = normalize_url(&url);
206
206
+
let server_name = name.unwrap_or_else(|| derive_server_name(&url));
207
207
+
let username = match username {
208
208
+
Some(u) => u,
209
209
+
None => prompt_line("Username: ")?,
210
210
+
};
211
211
+
let password = match password {
212
212
+
Some(p) => p,
213
213
+
None => prompt_password("Password: ")?,
214
214
+
};
215
215
+
let mut client = JellyfinClient::new(&url)?;
216
216
+
let auth = client.login(&username, &password).await?;
217
217
+
let server = ServerConfig {
218
218
+
name: server_name.clone(),
219
219
+
url,
220
220
+
user_id: auth.user.id.clone(),
221
221
+
user_name: auth.user.name.clone(),
222
222
+
access_token: auth.access_token.clone(),
223
223
+
device_id: client.device_id().to_string(),
224
224
+
};
225
225
+
config.add_or_update_server(server);
226
226
+
config.save()?;
227
227
+
println!(
228
228
+
"✓ signed in as {} on `{}` — now the active server",
229
229
+
auth.user.name, server_name
230
230
+
);
231
231
+
if config.servers.len() > 1 {
232
232
+
println!(
233
233
+
" ({} servers saved — switch anytime with `fin server switch <name>`)",
234
234
+
config.servers.len()
235
235
+
);
236
236
+
}
237
237
+
Ok(())
238
238
+
}
239
239
+
240
240
+
fn cmd_logout(config: &mut Config, name: Option<String>) -> Result<()> {
241
241
+
let target = name
242
242
+
.or_else(|| config.current_server.clone())
243
243
+
.context("no server to log out of")?;
244
244
+
config.remove_server(&target)?;
245
245
+
config.save()?;
246
246
+
println!("✓ removed `{}`", target);
247
247
+
if let Some(cur) = &config.current_server {
248
248
+
println!(" now active: `{}`", cur);
249
249
+
} else {
250
250
+
println!(" no servers left — run `fin login <url>` to add one");
251
251
+
}
252
252
+
Ok(())
253
253
+
}
254
254
+
255
255
+
fn cmd_server(config: &mut Config, sub: ServerCmd) -> Result<()> {
256
256
+
match sub {
257
257
+
ServerCmd::List => {
258
258
+
if config.servers.is_empty() {
259
259
+
println!("no servers yet — run `fin login <url>` to add one");
260
260
+
return Ok(());
261
261
+
}
262
262
+
let cur = config.current_server.as_deref();
263
263
+
for s in &config.servers {
264
264
+
let marker = if Some(s.name.as_str()) == cur {
265
265
+
"▍"
266
266
+
} else {
267
267
+
" "
268
268
+
};
269
269
+
println!(
270
270
+
"{} {:<20} {:<45} as {}",
271
271
+
marker, s.name, s.url, s.user_name
272
272
+
);
273
273
+
}
274
274
+
}
275
275
+
ServerCmd::Switch { name } => {
276
276
+
config.switch_to(&name)?;
277
277
+
config.save()?;
278
278
+
let s = config.current().unwrap();
279
279
+
println!(
280
280
+
"✓ active server → `{}` ({}, as {})",
281
281
+
s.name, s.url, s.user_name
282
282
+
);
283
283
+
}
284
284
+
ServerCmd::Rm { name } => {
285
285
+
config.remove_server(&name)?;
286
286
+
config.save()?;
287
287
+
println!("✓ removed `{}`", name);
288
288
+
}
289
289
+
ServerCmd::Rename { from, to } => {
290
290
+
let Some(idx) = config.servers.iter().position(|s| s.name == from) else {
291
291
+
anyhow::bail!("no server named `{}`", from);
292
292
+
};
293
293
+
if config.servers.iter().any(|s| s.name == to) {
294
294
+
anyhow::bail!("a server named `{}` already exists", to);
295
295
+
}
296
296
+
config.servers[idx].name = to.clone();
297
297
+
if config.current_server.as_deref() == Some(from.as_str()) {
298
298
+
config.current_server = Some(to.clone());
299
299
+
}
300
300
+
config.save()?;
301
301
+
println!("✓ `{}` → `{}`", from, to);
302
302
+
}
303
303
+
}
304
304
+
Ok(())
305
305
+
}
306
306
+
307
307
+
async fn cmd_search(cfg: &Config, query: &str, kind: Option<String>, limit: u32) -> Result<()> {
308
308
+
let client = make_client(cfg)?;
309
309
+
let types: Vec<&str> = match kind.as_deref() {
310
310
+
Some("audio") | Some("music") => vec!["Audio", "MusicAlbum", "MusicArtist"],
311
311
+
Some("video") | Some("movies") => vec!["Movie", "Series", "Episode"],
312
312
+
_ => vec![
313
313
+
"Audio",
314
314
+
"MusicAlbum",
315
315
+
"MusicArtist",
316
316
+
"Movie",
317
317
+
"Series",
318
318
+
"Episode",
319
319
+
],
320
320
+
};
321
321
+
let items = client.search(query, &types, limit).await?;
322
322
+
for it in &items {
323
323
+
println!("{} {} {}", it.kind().icon(), it.name, it.subtitle());
324
324
+
}
325
325
+
if items.is_empty() {
326
326
+
eprintln!("no matches");
327
327
+
}
328
328
+
Ok(())
329
329
+
}
330
330
+
331
331
+
async fn cmd_play(cfg: &Config, args: PlayArgs, queue: bool) -> Result<()> {
332
332
+
let client = make_client(cfg)?;
333
333
+
let format = if args.hls || cfg.renderer == RendererPref::Chromecast {
334
334
+
StreamFormat::Hls
335
335
+
} else {
336
336
+
StreamFormat::Direct
337
337
+
};
338
338
+
let types: Vec<&str> = match args.kind.as_deref() {
339
339
+
Some("audio") | Some("music") => vec!["Audio", "MusicAlbum"],
340
340
+
Some("video") | Some("movie") | Some("movies") => vec!["Movie", "Episode"],
341
341
+
_ => vec!["Audio", "MusicAlbum", "Movie", "Episode"],
342
342
+
};
343
343
+
let hits = client.search(&args.query, &types, 10).await?;
344
344
+
let Some(hit) = hits.into_iter().next() else {
345
345
+
anyhow::bail!("nothing matched '{}'", args.query);
346
346
+
};
347
347
+
let items = match hit.kind() {
348
348
+
ItemKind::MusicAlbum => {
349
349
+
client
350
350
+
.items(
351
351
+
Some(&hit.id),
352
352
+
&["Audio"],
353
353
+
false,
354
354
+
Some("SortName"),
355
355
+
Some(500),
356
356
+
)
357
357
+
.await?
358
358
+
}
359
359
+
_ => vec![hit],
360
360
+
};
361
361
+
let mut queue_items = Vec::new();
362
362
+
for it in items {
363
363
+
let url = client.stream_url(&it, format)?;
364
364
+
let is_video = it.kind().is_video();
365
365
+
queue_items.push(QueueItem {
366
366
+
id: it.id.clone(),
367
367
+
title: it.name.clone(),
368
368
+
subtitle: it.subtitle(),
369
369
+
stream_url: url,
370
370
+
image_url: None,
371
371
+
duration_secs: it.duration_secs(),
372
372
+
is_video,
373
373
+
content_type: if is_video {
374
374
+
"video/mp4".into()
375
375
+
} else {
376
376
+
"audio/mpeg".into()
377
377
+
},
378
378
+
});
379
379
+
}
380
380
+
let (renderer, label) = build_renderer(cfg).await?;
381
381
+
if queue {
382
382
+
renderer.enqueue(queue_items.clone()).await?;
383
383
+
println!("+ queued {} item(s) on {}", queue_items.len(), label);
384
384
+
} else {
385
385
+
renderer.play(queue_items.clone(), 0).await?;
386
386
+
println!(
387
387
+
"▶ playing “{}” on {}",
388
388
+
queue_items.first().map(|q| q.title.as_str()).unwrap_or(""),
389
389
+
label
390
390
+
);
391
391
+
}
392
392
+
// For chromecast, keep the process alive so playback keeps flowing.
393
393
+
if renderer.kind() == RendererKind::Chromecast {
394
394
+
println!("Ctrl+C to disconnect.");
395
395
+
tokio::signal::ctrl_c().await?;
396
396
+
}
397
397
+
Ok(())
398
398
+
}
399
399
+
400
400
+
async fn cmd_devices(scan_seconds: u64) -> Result<()> {
401
401
+
println!("scanning for Chromecasts ({}s)…", scan_seconds);
402
402
+
let devices = discover_chromecasts(Duration::from_secs(scan_seconds)).await?;
403
403
+
if devices.is_empty() {
404
404
+
eprintln!("no devices found");
405
405
+
return Ok(());
406
406
+
}
407
407
+
for d in devices {
408
408
+
println!(
409
409
+
" {} [{}] {}:{}",
410
410
+
d.display_name(),
411
411
+
d.model,
412
412
+
d.address,
413
413
+
d.port
414
414
+
);
415
415
+
}
416
416
+
Ok(())
417
417
+
}
418
418
+
419
419
+
async fn cmd_playlists(cfg: &Config, list_id: Option<String>) -> Result<()> {
420
420
+
let client = make_client(cfg)?;
421
421
+
match list_id {
422
422
+
None => {
423
423
+
let pls = client.playlists().await?;
424
424
+
for p in pls {
425
425
+
println!("▤ {} ({})", p.name, p.id);
426
426
+
}
427
427
+
}
428
428
+
Some(id) => {
429
429
+
let items = client.playlist_items(&id).await?;
430
430
+
for it in items {
431
431
+
println!("{} {} {}", it.kind().icon(), it.name, it.subtitle());
432
432
+
}
433
433
+
}
434
434
+
}
435
435
+
Ok(())
436
436
+
}
437
437
+
438
438
+
fn cmd_config(cfg: &Config, show: bool, path: bool) -> Result<()> {
439
439
+
if path {
440
440
+
println!("{}", fin_config::config_path()?.display());
441
441
+
return Ok(());
442
442
+
}
443
443
+
if show {
444
444
+
let text = toml::to_string_pretty(cfg).context("serialize config")?;
445
445
+
print!("{}", text);
446
446
+
return Ok(());
447
447
+
}
448
448
+
println!(
449
449
+
"config file: {}\nuse --show to print, --path to print just the path",
450
450
+
fin_config::config_path()?.display()
451
451
+
);
452
452
+
Ok(())
453
453
+
}
454
454
+
455
455
+
fn normalize_url(url: &str) -> String {
456
456
+
let trimmed = url.trim().trim_end_matches('/');
457
457
+
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
458
458
+
trimmed.to_string()
459
459
+
} else {
460
460
+
format!("http://{}", trimmed)
461
461
+
}
462
462
+
}
463
463
+
464
464
+
fn prompt_line(msg: &str) -> Result<String> {
465
465
+
print!("{}", msg);
466
466
+
io::stdout().flush().ok();
467
467
+
let mut buf = String::new();
468
468
+
io::stdin().read_line(&mut buf)?;
469
469
+
Ok(buf.trim().to_string())
470
470
+
}
471
471
+
472
472
+
/// Read a secret from the TTY without echoing it. Falls back to the standard
473
473
+
/// prompt only when stdin is not a TTY (e.g. piped input from a script) so
474
474
+
/// automation still works.
475
475
+
fn prompt_password(msg: &str) -> Result<String> {
476
476
+
let secret = rpassword::prompt_password(msg).context("reading password")?;
477
477
+
Ok(secret)
478
478
+
}
···
1
1
+
use std::process::Command;
2
2
+
3
3
+
use anyhow::{bail, Result};
4
4
+
5
5
+
/// Verify that mpv is on `$PATH`. fin uses mpv both as its local renderer and
6
6
+
/// as a fallback probe, so we refuse to start without it.
7
7
+
pub fn ensure_mpv() -> Result<()> {
8
8
+
let ok = Command::new("mpv")
9
9
+
.arg("--version")
10
10
+
.stdout(std::process::Stdio::null())
11
11
+
.stderr(std::process::Stdio::null())
12
12
+
.status()
13
13
+
.map(|s| s.success())
14
14
+
.unwrap_or(false);
15
15
+
if ok {
16
16
+
return Ok(());
17
17
+
}
18
18
+
let hint = if cfg!(target_os = "macos") {
19
19
+
"brew install mpv"
20
20
+
} else if cfg!(target_os = "linux") {
21
21
+
"sudo apt install mpv # or: sudo pacman -S mpv"
22
22
+
} else if cfg!(target_os = "windows") {
23
23
+
"winget install mpv # or: scoop install mpv"
24
24
+
} else {
25
25
+
"install mpv from https://mpv.io"
26
26
+
};
27
27
+
bail!(
28
28
+
"mpv is required but was not found on PATH.\n\nInstall it and try again:\n {}\n",
29
29
+
hint
30
30
+
);
31
31
+
}
···
1
1
+
{
2
2
+
"nodes": {
3
3
+
"advisory-db": {
4
4
+
"flake": false,
5
5
+
"locked": {
6
6
+
"lastModified": 1782982322,
7
7
+
"narHash": "sha256-+c+RMomeBqiXTajKR/Nrt/hddGDz8yks1bBm8B4+Crc=",
8
8
+
"owner": "rustsec",
9
9
+
"repo": "advisory-db",
10
10
+
"rev": "6cd8a86e08d8deb276cf46746cb789de39145481",
11
11
+
"type": "github"
12
12
+
},
13
13
+
"original": {
14
14
+
"owner": "rustsec",
15
15
+
"repo": "advisory-db",
16
16
+
"type": "github"
17
17
+
}
18
18
+
},
19
19
+
"crane": {
20
20
+
"locked": {
21
21
+
"lastModified": 1781825982,
22
22
+
"narHash": "sha256-SlXKwIRIhrOSAcTjCB3ftPLzJWZStQIPS7J1FlZPnKk=",
23
23
+
"owner": "ipetkov",
24
24
+
"repo": "crane",
25
25
+
"rev": "469fd08d0bcf6926321fa973c6777fbc87785dd7",
26
26
+
"type": "github"
27
27
+
},
28
28
+
"original": {
29
29
+
"owner": "ipetkov",
30
30
+
"repo": "crane",
31
31
+
"type": "github"
32
32
+
}
33
33
+
},
34
34
+
"fenix": {
35
35
+
"inputs": {
36
36
+
"nixpkgs": [
37
37
+
"nixpkgs"
38
38
+
],
39
39
+
"rust-analyzer-src": []
40
40
+
},
41
41
+
"locked": {
42
42
+
"lastModified": 1782900513,
43
43
+
"narHash": "sha256-GHrsl1+ysDFgmDQtQSqWS7TiYD2Q58VlwLvnf1+crJM=",
44
44
+
"owner": "nix-community",
45
45
+
"repo": "fenix",
46
46
+
"rev": "16810aa8f4ad89ca480b1513774e8b6f485fe368",
47
47
+
"type": "github"
48
48
+
},
49
49
+
"original": {
50
50
+
"owner": "nix-community",
51
51
+
"repo": "fenix",
52
52
+
"type": "github"
53
53
+
}
54
54
+
},
55
55
+
"flake-utils": {
56
56
+
"inputs": {
57
57
+
"systems": "systems"
58
58
+
},
59
59
+
"locked": {
60
60
+
"lastModified": 1731533236,
61
61
+
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
62
62
+
"owner": "numtide",
63
63
+
"repo": "flake-utils",
64
64
+
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
65
65
+
"type": "github"
66
66
+
},
67
67
+
"original": {
68
68
+
"owner": "numtide",
69
69
+
"repo": "flake-utils",
70
70
+
"type": "github"
71
71
+
}
72
72
+
},
73
73
+
"nixpkgs": {
74
74
+
"locked": {
75
75
+
"lastModified": 1782723713,
76
76
+
"narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=",
77
77
+
"owner": "NixOS",
78
78
+
"repo": "nixpkgs",
79
79
+
"rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8",
80
80
+
"type": "github"
81
81
+
},
82
82
+
"original": {
83
83
+
"owner": "NixOS",
84
84
+
"ref": "nixos-unstable",
85
85
+
"repo": "nixpkgs",
86
86
+
"type": "github"
87
87
+
}
88
88
+
},
89
89
+
"root": {
90
90
+
"inputs": {
91
91
+
"advisory-db": "advisory-db",
92
92
+
"crane": "crane",
93
93
+
"fenix": "fenix",
94
94
+
"flake-utils": "flake-utils",
95
95
+
"nixpkgs": "nixpkgs"
96
96
+
}
97
97
+
},
98
98
+
"systems": {
99
99
+
"locked": {
100
100
+
"lastModified": 1681028828,
101
101
+
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
102
102
+
"owner": "nix-systems",
103
103
+
"repo": "default",
104
104
+
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
105
105
+
"type": "github"
106
106
+
},
107
107
+
"original": {
108
108
+
"owner": "nix-systems",
109
109
+
"repo": "default",
110
110
+
"type": "github"
111
111
+
}
112
112
+
}
113
113
+
},
114
114
+
"root": "root",
115
115
+
"version": 7
116
116
+
}
···
1
1
+
{
2
2
+
description = "fin - a neon-electric Jellyfin TUI client for mpv & Chromecast";
3
3
+
4
4
+
inputs = {
5
5
+
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
6
6
+
7
7
+
crane = {
8
8
+
url = "github:ipetkov/crane";
9
9
+
inputs.nixpkgs.follows = "nixpkgs";
10
10
+
};
11
11
+
12
12
+
fenix = {
13
13
+
url = "github:nix-community/fenix";
14
14
+
inputs.nixpkgs.follows = "nixpkgs";
15
15
+
inputs.rust-analyzer-src.follows = "";
16
16
+
};
17
17
+
18
18
+
flake-utils.url = "github:numtide/flake-utils";
19
19
+
20
20
+
advisory-db = {
21
21
+
url = "github:rustsec/advisory-db";
22
22
+
flake = false;
23
23
+
};
24
24
+
};
25
25
+
26
26
+
outputs = { self, nixpkgs, crane, fenix, flake-utils, advisory-db, ... }:
27
27
+
flake-utils.lib.eachDefaultSystem (system:
28
28
+
let
29
29
+
pkgs = import nixpkgs {
30
30
+
inherit system;
31
31
+
};
32
32
+
33
33
+
inherit (pkgs) lib;
34
34
+
35
35
+
craneLib = crane.mkLib pkgs;
36
36
+
37
37
+
src = craneLib.cleanCargoSource ./.;
38
38
+
39
39
+
# fin uses rustls end-to-end, so no openssl.
40
40
+
# mpv is a *runtime* requirement (we spawn it for local playback and
41
41
+
# the CLI does a preflight `mpv --version` at startup). We fold it
42
42
+
# into PATH via a makeWrapper post-fixup below.
43
43
+
commonArgs = {
44
44
+
inherit src;
45
45
+
46
46
+
pname = "fin";
47
47
+
version = "0.1.0";
48
48
+
strictDeps = true;
49
49
+
50
50
+
# No native TLS or system libs needed — pure Rust deps.
51
51
+
nativeBuildInputs = [
52
52
+
pkgs.pkg-config
53
53
+
];
54
54
+
55
55
+
buildInputs = lib.optionals pkgs.stdenv.isDarwin [
56
56
+
pkgs.libiconv
57
57
+
pkgs.darwin.apple_sdk.frameworks.SystemConfiguration
58
58
+
pkgs.darwin.apple_sdk.frameworks.Security
59
59
+
];
60
60
+
61
61
+
# Workspace has one bin target — build just that.
62
62
+
cargoExtraArgs = "--locked --bin fin";
63
63
+
};
64
64
+
65
65
+
craneLibLLvmTools = craneLib.overrideToolchain
66
66
+
(fenix.packages.${system}.complete.withComponents [
67
67
+
"cargo"
68
68
+
"llvm-tools"
69
69
+
"rustc"
70
70
+
]);
71
71
+
72
72
+
# Cache the dependency graph separately from the crate source.
73
73
+
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
74
74
+
75
75
+
finUnwrapped = craneLib.buildPackage (commonArgs // {
76
76
+
inherit cargoArtifacts;
77
77
+
doCheck = false;
78
78
+
});
79
79
+
80
80
+
# Wrap the binary so `mpv` is always discoverable at runtime, even
81
81
+
# when installed via `nix profile install`.
82
82
+
fin = pkgs.symlinkJoin {
83
83
+
name = "fin-${finUnwrapped.version}";
84
84
+
paths = [ finUnwrapped ];
85
85
+
nativeBuildInputs = [ pkgs.makeWrapper ];
86
86
+
postBuild = ''
87
87
+
wrapProgram $out/bin/fin \
88
88
+
--prefix PATH : ${lib.makeBinPath [ pkgs.mpv ]}
89
89
+
'';
90
90
+
meta = {
91
91
+
description = "A neon-electric Jellyfin TUI client for mpv & Chromecast";
92
92
+
homepage = "https://github.com/tsirysndr/fin";
93
93
+
license = lib.licenses.mpl20;
94
94
+
mainProgram = "fin";
95
95
+
platforms = lib.platforms.unix;
96
96
+
};
97
97
+
};
98
98
+
99
99
+
in
100
100
+
{
101
101
+
checks = {
102
102
+
inherit fin;
103
103
+
104
104
+
fin-clippy = craneLib.cargoClippy (commonArgs // {
105
105
+
inherit cargoArtifacts;
106
106
+
cargoClippyExtraArgs = "--all-targets -- --deny warnings";
107
107
+
});
108
108
+
109
109
+
fin-doc = craneLib.cargoDoc (commonArgs // {
110
110
+
inherit cargoArtifacts;
111
111
+
});
112
112
+
113
113
+
fin-fmt = craneLib.cargoFmt {
114
114
+
inherit src;
115
115
+
};
116
116
+
117
117
+
fin-audit = craneLib.cargoAudit {
118
118
+
inherit src advisory-db;
119
119
+
};
120
120
+
121
121
+
fin-nextest = craneLib.cargoNextest (commonArgs // {
122
122
+
inherit cargoArtifacts;
123
123
+
partitions = 1;
124
124
+
partitionType = "count";
125
125
+
});
126
126
+
} // lib.optionalAttrs (system == "x86_64-linux") {
127
127
+
fin-coverage = craneLib.cargoTarpaulin (commonArgs // {
128
128
+
inherit cargoArtifacts;
129
129
+
});
130
130
+
};
131
131
+
132
132
+
packages = {
133
133
+
default = fin;
134
134
+
fin = fin;
135
135
+
fin-unwrapped = finUnwrapped;
136
136
+
137
137
+
fin-llvm-coverage = craneLibLLvmTools.cargoLlvmCov (commonArgs // {
138
138
+
inherit cargoArtifacts;
139
139
+
});
140
140
+
};
141
141
+
142
142
+
apps.default = flake-utils.lib.mkApp {
143
143
+
drv = fin;
144
144
+
name = "fin";
145
145
+
};
146
146
+
147
147
+
devShells.default = pkgs.mkShell {
148
148
+
inputsFrom = builtins.attrValues self.checks.${system};
149
149
+
150
150
+
# Everything you need to `cargo run` and actually play back media.
151
151
+
nativeBuildInputs = with pkgs; [
152
152
+
cargo
153
153
+
rustc
154
154
+
rustfmt
155
155
+
clippy
156
156
+
rust-analyzer
157
157
+
mpv
158
158
+
pkg-config
159
159
+
] ++ lib.optionals pkgs.stdenv.isDarwin [
160
160
+
libiconv
161
161
+
darwin.apple_sdk.frameworks.SystemConfiguration
162
162
+
darwin.apple_sdk.frameworks.Security
163
163
+
];
164
164
+
165
165
+
shellHook = ''
166
166
+
echo "⚡ fin dev shell — mpv $(mpv --version | head -n1 | cut -d' ' -f2) ready"
167
167
+
'';
168
168
+
};
169
169
+
});
170
170
+
}