/ src / index.ts
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();