/ src / jfetch.ts
jfetch.ts
  1  import { ImageType, Item, Jellyfin, MediaSource } from "./jellyfin";
  2  import { posix as path } from 'path';
  3  import { FetchTask, ImageTask, MediaTask, NfoTask, ExternalStreamTask } from "./index.js";
  4  
  5  const patterns = {
  6    Movie: "{Name} ({ProductionYear})",
  7    SeriesFolder: "{Name} ({ProductionYear})",
  8    SeasonFolder: "{Name}",
  9    StripChars: /[:*<>\"?|\\\/]/g,
 10  };
 11  
 12  export class JFetch {
 13    constructor(
 14      private readonly jserver:Jellyfin,
 15    ) {}
 16  
 17    private readonly seenItems = new Map<string, Item>();
 18  
 19    public async fetchItemInfo(id:string) {
 20      let item = this.seenItems.get(id);
 21      if (!item) {
 22        item = await this.jserver.getItem(id);
 23        this.seenItems.set(id, item);
 24      }
 25      return item;
 26    }
 27  
 28    private async ItemPath(itemSpec:Item|string) {
 29      const item =  (typeof itemSpec === 'string') ? await this.fetchItemInfo(itemSpec) : itemSpec;
 30      let pattern:string;
 31      switch (item.Type) {
 32        case "Series":
 33          pattern = patterns.SeriesFolder;
 34          break;
 35        case "Season":
 36          pattern = patterns.SeasonFolder;
 37          break;
 38        case "Movie":
 39          pattern = patterns.Movie;
 40          break;
 41        default:
 42          throw `No path pattern for ${item.Type} Items`;
 43      }
 44      return pattern.replace(/\{([a-zA-Z]+)\}/g, (s, token:string)=>{
 45        if (item.hasOwnProperty(token)) {
 46          const tok = item[<keyof Item>token];
 47          if (typeof tok === 'string') {
 48            return tok.replace(patterns.StripChars, '');
 49          }
 50          if (typeof tok === 'number') {
 51            return tok.toString();
 52          }
 53        }
 54        return s;
 55      });
 56    }
 57  
 58    public async *fetchItem(itemSpec:Item|string, shallow?:boolean): AsyncGenerator<FetchTask> {
 59      const item =  (typeof itemSpec === 'string') ? await this.fetchItemInfo(itemSpec) : itemSpec;
 60      switch (item.Type) {
 61        case "Series":
 62          yield *this.fetchSeries(item, shallow);
 63          break;
 64        case "Season":
 65          yield *this.fetchSeason(item, shallow);
 66          break;
 67        case "Episode":
 68          yield *this.fetchEpisode(item);
 69          break;
 70        case "Movie":
 71          yield *this.fetchMovie(item);
 72          break;
 73        case "BoxSet":
 74        case "Playlist":
 75        case "CollectionFolder":
 76          yield *this.fetchCollection(item, shallow);
 77          break;
 78        default:
 79          console.log(`Downloading ${item.Type} Items not yet supported`);
 80          break;
 81      }
 82    }
 83  
 84    
 85    private async *fetchCollection(item:Item, shallow?:boolean) {
 86      const children = await this.jserver.getItemChildren(item.Id);
 87      for (const child of children.Items) {
 88        yield* this.fetchItem(child, shallow);
 89      }
 90    }
 91  
 92    private async *fetchMedia(item:Item, dirpath:string, media:MediaSource, with_images?:boolean) {
 93      if (!media.Name) {
 94        console.log(`No name for media ${media.Id!} on Item ${item.Id}`);
 95        return;
 96      }
 97      const medianame = media.Name.replace(patterns.StripChars, '');
 98      const vidpath = path.join(dirpath, `${medianame}.${media.Container}`);
 99      
100      const nfopath = path.join(dirpath, `${medianame}.nfo`);
101      yield new NfoTask(nfopath, item);
102      for (const stream of media.MediaStreams!) {
103        if (stream.IsExternal) {
104          let streampath = path.join(dirpath, medianame);
105          if (stream.Title) { streampath += `.${stream.Title}`; }
106          if (stream.Language) { streampath += `.${stream.Language}`; }
107          if (stream.IsDefault) { streampath += `.default`; }
108          if (stream.IsForced) { streampath += `.forced`; }
109          switch (stream.Type) {
110            case "Subtitle":
111              switch (stream.Codec) {
112                case "srt":
113                  yield new ExternalStreamTask(`${streampath}.srt`, ()=>this.jserver.getSubtitle(item.Id, media.Id!, stream.Index, "srt"));
114                  break;
115                case "webvtt":
116                  yield new ExternalStreamTask(`${streampath}.vtt`, ()=>this.jserver.getSubtitle(item.Id, media.Id!, stream.Index, "vtt"));
117                  break;
118                default:
119                  console.log(`Downloading ${stream.Codec} Subtitle streams not yet supported`);
120                  break;
121              }
122              break;
123            default:
124              console.log(`Downloading ${stream.Type} streams not yet supported`);
125              break;
126          }
127        }
128      }
129  
130      yield new MediaTask(vidpath, ()=>this.jserver.getFile(media.Id!), media.Size!);
131  
132      if (with_images) {
133        yield* this.fetchImages(item, dirpath, `${medianame}-`);
134      }
135    }
136  
137    private async *fetchImages(item:Item, dirpath:string, prefix?:string) {
138      const images = await this.jserver.getItemImageInfo(item.Id);
139      for (const ii of images) {
140        const headers = await this.jserver.getItemImageHeaders(item.Id, ii.ImageType, ii.ImageIndex);
141        const contenttype = headers.get("content-type");
142        const imageext = contenttype === "image/jpeg" ? "jpg" :
143          undefined;
144          
145        const imagename = ii.ImageType==="Primary" ?
146          (prefix?"thumb":"folder"):
147          ii.ImageType.toLowerCase();
148  
149        const imagepath = path.join(
150          dirpath,
151          `${prefix??''}${imagename}${ii.ImageIndex?ii.ImageIndex:''}.${imageext}`
152        );
153        yield new ImageTask(imagepath, ()=>this.jserver.getItemImage(item.Id, ii.ImageType, ii.ImageIndex), ii.Size);
154      }
155    }
156  
157    private async *fetchMovie(movie:Item) {
158      const dirpath = path.join(await this.ItemPath(movie));
159      for (const media of movie.MediaSources!) {
160        yield* this.fetchMedia(movie, dirpath, media);
161      }
162      yield* this.fetchImages(movie, dirpath);
163    }
164  
165    private async *fetchEpisode(episode:Item) {
166      const dirpath = path.join(...await Promise.all([episode.SeriesId!, episode.SeasonId!].map(this.ItemPath, this)));
167      for (const media of episode.MediaSources!) {
168        if (media.Type === "Default") {
169          yield* this.fetchMedia(episode, dirpath, media, true);
170        }
171      }
172    }
173  
174    private async *fetchSeason(season:Item, shallow?:boolean) {
175      const dirpath = path.join(...await Promise.all([season.SeriesId!, season].map(this.ItemPath, this)));
176      const seasonnfo = path.join(dirpath, "season.nfo");
177      yield new NfoTask(seasonnfo, season);
178  
179      yield* this.fetchImages(season, dirpath);
180  
181      if (!shallow) {
182        const episodes = await this.jserver.getEpisodes(season.SeriesId!, season.Id);
183        for (const episode of episodes.Items) {
184          this.seenItems.set(episode.Id, episode);
185          yield* this.fetchEpisode(episode);
186        }
187      }
188    }
189  
190    private async *fetchSeries(series:Item, shallow?:boolean) {
191      const dirpath = await this.ItemPath(series);
192      const seriesnfo = path.join(dirpath, "tvshow.nfo");
193      yield new NfoTask(seriesnfo, series);
194  
195      yield* this.fetchImages(series, dirpath);
196  
197      if (!shallow) {
198        const seasons = await this.jserver.getSeasons(series.Id);
199        for (const season of seasons.Items) {
200          this.seenItems.set(season.Id, season);
201          yield* this.fetchSeason(season);
202        }
203      }
204    }
205  
206  }