index.ts
1 #!/usr/bin/env node 2 3 import { Item, Jellyfin } from './jellyfin.js'; 4 import { JFetch } from './jfetch.js'; 5 6 import { program } from 'commander'; 7 import * as inquirer from "@inquirer/prompts"; 8 9 import { filesize } from 'filesize'; 10 import * as fs from 'fs'; 11 import * as fsp from 'fs/promises'; 12 import { posix as path } from 'path'; 13 import { pipeline } from 'stream'; 14 import { promisify } from 'util'; 15 const pipelineAsync = promisify(pipeline); 16 import progress_stream from "progress-stream"; 17 import cliprog, { MultiBar } from "cli-progress"; 18 import * as async from 'async'; 19 import { makeNfo } from './nfowriter.js'; 20 21 async function getAuthedJellyfinApi(server:string) { 22 const jserver = await Jellyfin.getApiSession( 23 server, 24 undefined, 25 async ()=>{ 26 return { 27 username: await inquirer.input({ 28 message: "Username:", 29 }), 30 password: await inquirer.password({ 31 message: "Password:", 32 }), 33 }; 34 }); 35 return new JFetch(jserver); 36 } 37 38 39 40 const bars = Object.assign(new MultiBar({ 41 format: '[{bar}] | {percentage}% | {value} / {total} | {filename} {speed}', 42 formatValue: function(v, options, type) { 43 switch (type) { 44 case 'value': 45 case 'total': 46 return filesize(v).padStart(10); 47 default: 48 return cliprog.Format.ValueFormat(v, options, type); 49 } 50 }, 51 autopadding: true, 52 forceRedraw: true, 53 }), { 54 create_count: function(total:number, message:string, startValue:number=0, payload?:any, options?:cliprog.Options) { 55 return bars.create(total, startValue, 56 Object.assign({}, payload), 57 Object.assign<cliprog.Options, cliprog.Options|undefined>({ 58 format: `[{bar}] | {percentage}% | {value} / {total} | ${message}`, 59 formatValue: function(v, options, type) { 60 switch (type) { 61 case 'total': 62 if (v===0) { return "?".padStart(7); } 63 case 'value': 64 return v.toString().padStart(7); 65 default: 66 return cliprog.Format.ValueFormat(v, options, type); 67 } 68 }, 69 emptyOnZero: true, 70 }, options)); 71 }, 72 }); 73 74 function toHHMMSS(totalseconds:number) { 75 const hours = Math.floor(totalseconds / 3600); 76 const minutes = Math.floor(totalseconds / 60) % 60; 77 const seconds = totalseconds % 60; 78 79 return [hours, minutes, seconds] 80 .map(v=>v < 10 ? "0" + v : v) 81 .filter((v, i)=>v !== "00" || i > 0) 82 .join(":"); 83 } 84 85 async function writeFileProgress(dest:string, file:string, data:string|Promise<NodeJS.ReadableStream>, size?:number) { 86 const filepath = path.join(dest, file); 87 const filename = path.basename(file); 88 const dir = fsp.mkdir(path.dirname(filepath), {recursive: true}); 89 const bar = size && size > (1024*1024) && typeof data !== "string" && 90 bars.create(size, 0, {speed: "", filename: filename}); 91 const ps = progress_stream( 92 { time: 200 }, 93 progress=>bar && bar.update(progress.transferred, {speed: filesize(progress.speed)+"/s", filename: filename}) 94 ); 95 await dir; 96 try { 97 await pipelineAsync(await data, ps, fs.createWriteStream(filepath+".tmp")); 98 await fsp.rename(filepath+".tmp", filepath); 99 } catch (error) { 100 try { 101 fsp.unlink(filepath+".tmp"); 102 } catch (error) { 103 104 } 105 } 106 const progress = ps.progress(); 107 if (bar) { 108 bar.stop(); 109 bars.remove(bar); 110 } 111 bars.log(`${filesize(progress.transferred).padStart(10)} ${toHHMMSS(progress.runtime).padStart(8)} ${file}\n`); 112 } 113 114 type FetchType = "nfo"|"media"|"image"|"external"; 115 export abstract class FetchTask { 116 abstract readonly type:FetchType; 117 constructor ( 118 readonly destpath:string, 119 ) {} 120 121 protected abstract get data() : string|Promise<NodeJS.ReadableStream>; 122 public abstract get size() : number|undefined; 123 124 public async Execute(dest:string) { 125 return writeFileProgress(dest, this.destpath, this.data, this.size); 126 } 127 } 128 129 export class NfoTask extends FetchTask { 130 readonly type = "nfo"; 131 readonly data:string; 132 133 constructor( 134 readonly destpath:string, 135 item:Item, 136 ) { 137 super(destpath); 138 this.data = makeNfo(item); 139 } 140 141 public get size() { 142 return this.data.length; 143 } 144 } 145 146 export abstract class BaseStreamTask extends FetchTask { 147 constructor( 148 readonly destpath:string, 149 private readonly datareq:()=>Promise<NodeJS.ReadableStream>, 150 private readonly _size?: number, 151 ) { 152 super(destpath); 153 } 154 155 private _data?:Promise<NodeJS.ReadableStream>; 156 protected get data() { 157 if (!this._data) { 158 this._data = this.datareq(); 159 } 160 return this._data; 161 } 162 public get size() { return this._size; } 163 } 164 165 export class MediaTask extends BaseStreamTask { 166 readonly type = "media"; 167 } 168 169 export class ImageTask extends BaseStreamTask { 170 readonly type = "image"; 171 } 172 173 export class ExternalStreamTask extends BaseStreamTask { 174 readonly type = "external"; 175 } 176 177 178 179 type ProgramOptions = { 180 dest:string 181 list:boolean 182 shallow:boolean 183 } & {[f in FetchType]:boolean}; 184 185 program 186 .version('0.0.1') 187 .description("download content from a jellyfin server") 188 .argument("<server>", "Base url of the server") 189 .argument("<ids...>", "ItemIDs to fetch.") 190 .option("-d, --dest <destination>", "Destination folder", ".") 191 .option("-l, --list", "List files that will be downloaded.") 192 .option("-s, --shallow", "When fetching Series or Season items, fetch only the specified item, not children.") 193 .option("-n, --no-nfo", "Skip Nfo files.") 194 .option("-m, --no-media", "Skip Media files.") 195 .option("-i, --no-image", "Skip Image files.") 196 .option("-x, --no-external", "Skip external media streams (usually subs).") 197 198 .action(async (server:string, ids:string[])=>{ 199 const opts = program.opts<ProgramOptions>(); 200 const jfetch = await getAuthedJellyfinApi(server); 201 202 const items = await Promise.all(ids.map(id=>jfetch.fetchItemInfo(id))); 203 204 items.map(item=>{ 205 let message = item.Id; 206 if (item.Type) { message += " " + item.Type; } 207 if (item.SeriesName) { message += " " + item.SeriesName; } 208 if (item.SeasonName) { message += " " + item.SeasonName; } 209 if (item.Name) { message += " " + item.Name; } 210 if (item.RecursiveItemCount) { 211 message += ` [${item.RecursiveItemCount} items]`; 212 } 213 console.log(message); 214 }); 215 216 let ptasks = []; 217 const tbar = bars.create_count(0, "Collecting metadata..."); 218 for (const item of items) { 219 for await (const task of jfetch.fetchItem(item, opts.shallow)) { 220 if (opts[task.type]) { 221 ptasks.push(fsp.stat(path.join(opts.dest, task.destpath)) 222 .catch(()=>undefined) 223 .then(stat=>{ 224 tbar.increment(); 225 return {task: task, stat: stat}; 226 })); 227 } 228 } 229 } 230 231 let tasks = await Promise.all(ptasks); 232 tbar.stop(); 233 bars.remove(tbar); 234 bars.stop(); 235 236 if (opts.list) { 237 tasks = (await inquirer.checkbox({ 238 message: `Files to download:`, 239 loop: false, 240 choices: tasks.map(task=>{ 241 const tasksize = 242 task.stat ? `${filesize(task.stat.size)} => ${task.task.size ? filesize(task.task.size): 'unknown'}` : 243 task.task.size ? filesize(task.task.size) : 244 ''; 245 return { 246 name: `${task.task.destpath} ${tasksize}`, 247 value: task, 248 checked: !task.stat, 249 }; 250 }), 251 })); 252 } else { 253 const withstats = tasks.filter(t=>t.stat); 254 if (withstats.length > 0) { 255 const overwrite: typeof withstats = (await inquirer.checkbox({ 256 message: "Overwrite existing files?", 257 loop: false, 258 choices: withstats.map(t=>{ 259 return { 260 name: `${t.task.destpath} ${filesize(t.stat!.size)} => ${t.task.size ? filesize(t.task.size): 'unknown'}`, 261 value: t, 262 }; 263 }), 264 })); 265 const skip = withstats.filter(s=>!overwrite.includes(s)); 266 tasks = tasks.filter(t=>!skip.includes(t)); 267 } 268 269 if (!(await inquirer.confirm({ 270 message: `Download ${filesize(tasks.reduce((a, b)=>(a)+(b.task.size??0), 0))}?`, 271 }))) { 272 return; 273 } 274 } 275 276 const grouped_tasks = tasks.map(t=>t.task).reduce(function (r, a) { 277 r[a.type] = r[a.type] || []; 278 r[a.type].push(a); 279 return r; 280 }, <{[k in FetchType]:FetchTask[]}>{} ); 281 282 const qs = []; 283 for (const key in grouped_tasks) { 284 if (Object.prototype.hasOwnProperty.call(grouped_tasks, key)) { 285 const type = key as FetchType; 286 const tasks = grouped_tasks[type]; 287 if (opts[type] && tasks.length > 0) { 288 const tbar = bars.create_count(tasks.length, type); 289 const q = async.queue<FetchTask>(async(task)=>{ 290 await task.Execute(opts.dest); 291 tbar.increment(); 292 }, 1); 293 q.push(tasks); 294 q.drain(()=>{ 295 tbar.stop(); 296 bars.remove(tbar); 297 }); 298 qs.push(q); 299 } 300 } 301 } 302 303 await Promise.all(qs.map(q=>q.drain())); 304 bars.stop(); 305 }) 306 .parseAsync();