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 }