forked from DebaucheryLibrarian/traxxx
295 lines
10 KiB
JavaScript
295 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
const config = require('config');
|
|
const Promise = require('bluebird');
|
|
const path = require('path');
|
|
const fs = require('fs-extra');
|
|
const bhttp = require('bhttp');
|
|
const mime = require('mime');
|
|
const sharp = require('sharp');
|
|
const blake2 = require('blake2');
|
|
|
|
const knex = require('./knex');
|
|
|
|
function getHash(buffer) {
|
|
const hash = blake2.createHash('blake2b', { digestLength: 24 });
|
|
|
|
hash.update(buffer);
|
|
|
|
return hash.digest('hex');
|
|
}
|
|
|
|
function pluckPhotos(photos, release, specifiedLimit) {
|
|
const limit = specifiedLimit || config.media.limit;
|
|
|
|
if (photos.length <= limit) {
|
|
return photos;
|
|
}
|
|
|
|
const plucked = [1]
|
|
.concat(
|
|
Array.from({ length: limit }, (value, index) => Math.round((index + 1) * (photos.length / (limit)))),
|
|
);
|
|
|
|
return Array.from(new Set(plucked)).map(photoIndex => photos[photoIndex - 1]); // remove duplicates, may happen when photo total and photo limit are close
|
|
}
|
|
|
|
async function getThumbnail(buffer) {
|
|
return sharp(buffer)
|
|
.resize({
|
|
height: config.media.thumbnailSize,
|
|
withoutEnlargement: true,
|
|
})
|
|
.toBuffer();
|
|
}
|
|
|
|
async function createReleaseMediaDirectory(release, releaseId) {
|
|
if (release.poster || (release.photos && release.photos.length) || release.trailer) {
|
|
await fs.mkdir(
|
|
path.join(config.media.path, 'releases', release.site.network.slug, release.site.slug, releaseId.toString()),
|
|
{ recursive: true },
|
|
);
|
|
}
|
|
}
|
|
|
|
async function createActorMediaDirectory(profile, actor) {
|
|
if (profile.avatars && profile.avatars.length) {
|
|
await fs.mkdir(
|
|
path.join(config.media.path, 'actors', actor.slug),
|
|
{ recursive: true },
|
|
);
|
|
}
|
|
}
|
|
|
|
function curatePhotoEntries(files, domain = 'releases', role = 'photo', targetId, setAvatar = false) {
|
|
return files.map((file, index) => ({
|
|
path: file.filepath,
|
|
thumbnail: file.thumbpath,
|
|
mime: file.mimetype,
|
|
hash: file.hash,
|
|
source: file.source,
|
|
index,
|
|
domain,
|
|
target_id: targetId,
|
|
role: setAvatar && index === 0 ? 'avatar' : role,
|
|
}));
|
|
}
|
|
|
|
// before fetching
|
|
async function filterSourceDuplicates(photos, domains = ['releases'], roles = ['photo'], identifier) {
|
|
const photoSourceEntries = await knex('media')
|
|
.whereIn('source', photos)
|
|
.whereIn('domain', [].concat(domains))
|
|
.whereIn('role', [].concat(roles)); // accept string argument
|
|
|
|
const photoSources = new Set(photoSourceEntries.map(photo => photo.source));
|
|
const newPhotos = photos.filter(source => !photoSources.has(source));
|
|
|
|
if (photoSourceEntries.length > 0) {
|
|
console.log(`Ignoring ${photoSourceEntries.length} ${roles} items already present by source for ${identifier}`);
|
|
}
|
|
|
|
return newPhotos;
|
|
}
|
|
|
|
// after fetching
|
|
async function filterHashDuplicates(files, domains = ['releases'], roles = ['photo'], identifier) {
|
|
const photoHashEntries = await knex('media')
|
|
.whereIn('hash', files.map(file => file.hash))
|
|
.whereIn('domain', [].concat(domains))
|
|
.whereIn('role', [].concat(roles)); // accept string argument
|
|
|
|
const photoHashes = new Set(photoHashEntries.map(entry => entry.hash));
|
|
|
|
if (photoHashEntries.length > 0) {
|
|
console.log(`Ignoring ${photoHashEntries.length} ${roles} items already present by hash for ${identifier}`);
|
|
}
|
|
|
|
return files.filter(file => file && !photoHashes.has(file.hash));
|
|
}
|
|
|
|
async function fetchPhoto(photoUrl, index, identifier, attempt = 1) {
|
|
if (Array.isArray(photoUrl)) {
|
|
return fetchPhoto(photoUrl[0], index, identifier);
|
|
// return photoUrl.reduce(async (outcome, url) => outcome.catch(async () => fetchPhoto(url, index, identifier)), Promise.reject());
|
|
}
|
|
|
|
try {
|
|
const { pathname } = new URL(photoUrl);
|
|
const mimetype = mime.getType(pathname);
|
|
|
|
const res = await bhttp.get(photoUrl);
|
|
|
|
if (res.statusCode === 200) {
|
|
const extension = mime.getExtension(mimetype);
|
|
const hash = getHash(res.body);
|
|
|
|
return {
|
|
photo: res.body,
|
|
mimetype,
|
|
extension,
|
|
hash,
|
|
source: photoUrl,
|
|
};
|
|
}
|
|
|
|
throw new Error(`Response ${res.statusCode} not OK`);
|
|
} catch (error) {
|
|
console.warn(`Failed attempt ${attempt}/3 to fetch photo ${index + 1} (${photoUrl}) for ${identifier}: ${error}`);
|
|
|
|
if (attempt < 3) {
|
|
await Promise.delay(1000);
|
|
return fetchPhoto(photoUrl, index, identifier, attempt + 1);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function savePhotos(files, release, releaseId, actorSlug, isPoster = false) {
|
|
return Promise.map(files, async (file, index) => {
|
|
const timestamp = new Date().getTime();
|
|
const thumbnail = await getThumbnail(file.photo);
|
|
|
|
const filepath = actorSlug
|
|
? path.join('actors', actorSlug, `${timestamp + index}.${file.extension}`)
|
|
: path.join('releases', release.site.network.slug, release.site.slug, releaseId.toString(), `${isPoster ? 'poster' : index + 1}.${file.extension}`);
|
|
|
|
const thumbpath = actorSlug
|
|
? path.join('actors', actorSlug, `${timestamp + index}_thumb.${file.extension}`)
|
|
: path.join('releases', release.site.network.slug, release.site.slug, releaseId.toString(), `${isPoster ? 'poster' : index + 1}_thumb.${file.extension}`);
|
|
|
|
await Promise.all([
|
|
fs.writeFile(path.join(config.media.path, filepath), file.photo),
|
|
fs.writeFile(path.join(config.media.path, thumbpath), thumbnail),
|
|
]);
|
|
|
|
return {
|
|
...file,
|
|
thumbnail,
|
|
filepath,
|
|
thumbpath,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function storePoster(release, releaseId) {
|
|
if (!release.poster) {
|
|
console.warn(`No poster available for (${release.site.name}, ${releaseId}}) "${release.title}"`);
|
|
return;
|
|
}
|
|
const [newPoster] = await filterSourceDuplicates([release.poster], 'releases', 'poster', `(${release.site.name}, ${releaseId}) "${release.title}"`);
|
|
|
|
if (!newPoster) return;
|
|
|
|
console.log(`Fetching poster for (${release.site.name}, ${releaseId}) "${release.title}"`);
|
|
|
|
const metaFile = await fetchPhoto(release.poster, null, `(${release.site.name}, ${releaseId}) "${release.title}"`);
|
|
const [uniquePoster] = await filterHashDuplicates([metaFile], 'releases', 'poster', `(${release.site.name}, ${releaseId}) "${release.title}"`);
|
|
|
|
if (!uniquePoster) return;
|
|
|
|
const savedPosters = await savePhotos([uniquePoster], release, releaseId, null, true);
|
|
|
|
await knex('media').insert(curatePhotoEntries(savedPosters, 'releases', 'poster', releaseId));
|
|
}
|
|
|
|
|
|
async function storePhotos(release, releaseId) {
|
|
if (!release.photos || release.photos.length === 0) {
|
|
console.warn(`No photos available for (${release.site.name}, ${releaseId}) "${release.title}"`);
|
|
return;
|
|
}
|
|
|
|
const pluckedPhotos = pluckPhotos(release.photos, release);
|
|
|
|
const newPhotos = await filterSourceDuplicates(pluckedPhotos, 'releases', 'photo', `(${release.site.name}, ${releaseId}) "${release.title}"`);
|
|
|
|
if (newPhotos.length === 0) return;
|
|
|
|
console.log(`Fetching ${newPhotos.length} photos for (${release.site.name}, ${releaseId}) "${release.title}"`);
|
|
|
|
const metaFiles = await Promise.map(newPhotos, async (photoUrl, index) => fetchPhoto(photoUrl, index, `(${release.site.name}, ${releaseId}) "${release.title}"`), {
|
|
concurrency: 10,
|
|
}).filter(photo => photo);
|
|
|
|
const uniquePhotos = await filterHashDuplicates(metaFiles, 'releases', 'photo', `(${release.site.name}, ${releaseId}) "${release.title}"`);
|
|
const savedPhotos = await savePhotos(uniquePhotos, release, releaseId);
|
|
|
|
await knex('media').insert(curatePhotoEntries(savedPhotos, 'releases', 'photo', releaseId));
|
|
|
|
console.log(`Stored ${newPhotos.length} photos for (${release.site.name}, ${releaseId}) "${release.title}"`);
|
|
}
|
|
|
|
async function storeTrailer(release, releaseId) {
|
|
// support scrapers supplying multiple qualities
|
|
const trailer = Array.isArray(release.trailer)
|
|
? (release.trailer.find(trailerX => [1080, 720].includes(trailerX.quality) || release.trailer[0]))
|
|
: release.trailer;
|
|
|
|
if (!trailer || !trailer.src) {
|
|
console.warn(`No trailer available for (${release.site.name}, ${releaseId}}) "${release.title}"`);
|
|
return;
|
|
}
|
|
|
|
console.log(`Storing trailer for (${release.site.name}, ${releaseId}) "${release.title}"`);
|
|
|
|
const { pathname } = new URL(trailer.src);
|
|
const mimetype = trailer.type || mime.getType(pathname);
|
|
|
|
const res = await bhttp.get(trailer.src);
|
|
const filepath = path.join('releases', release.site.network.slug, release.site.slug, releaseId.toString(), `trailer${trailer.quality ? `_${trailer.quality}` : ''}.${mime.getExtension(mimetype)}`);
|
|
|
|
await Promise.all([
|
|
fs.writeFile(path.join(config.media.path, filepath), res.body),
|
|
knex('media').insert({
|
|
path: filepath,
|
|
mime: mimetype,
|
|
source: trailer.src,
|
|
domain: 'releases',
|
|
target_id: releaseId,
|
|
role: 'trailer',
|
|
quality: trailer.quality || null,
|
|
}),
|
|
]);
|
|
}
|
|
|
|
async function storeAvatars(profile, actor) {
|
|
if (!profile.avatars || profile.avatars.length === 0) {
|
|
console.warn(`No avatars available for '${profile.name}'`);
|
|
return;
|
|
}
|
|
|
|
const newPhotos = await filterSourceDuplicates(profile.avatars, 'actors', ['avatar', 'photo'], actor.name);
|
|
|
|
if (newPhotos.length === 0) return;
|
|
|
|
console.log(`Fetching ${newPhotos.length} avatars for '${actor.name}'`);
|
|
|
|
const metaFiles = await Promise.map(newPhotos, async (photoUrl, index) => fetchPhoto(photoUrl, index, actor.name), {
|
|
concurrency: 10,
|
|
}).filter(photo => photo);
|
|
|
|
const uniquePhotos = await filterHashDuplicates(metaFiles, 'actors', ['avatar', 'photo'], actor.name);
|
|
const [savedPhotos, avatarEntry] = await Promise.all([
|
|
savePhotos(uniquePhotos, null, null, actor.slug),
|
|
knex('media').where({
|
|
target_id: actor.id,
|
|
domain: 'actors',
|
|
role: 'avatar',
|
|
}).first(),
|
|
]);
|
|
|
|
// if no avatar entry is present, curatePhotoEntries will store the first photo as avatar
|
|
await knex('media').insert(curatePhotoEntries(savedPhotos, 'actors', 'photo', actor.id, !avatarEntry));
|
|
}
|
|
|
|
module.exports = {
|
|
createActorMediaDirectory,
|
|
createReleaseMediaDirectory,
|
|
storeAvatars,
|
|
storePoster,
|
|
storePhotos,
|
|
storeTrailer,
|
|
};
|