@ -1,22 +0,0 @@ | |||
import { BaseEntity, Column, CreateDateColumn, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm"; | |||
import { DiscordRequest } from "./DiscordRequest"; | |||
import { User } from "./User"; | |||
@Entity() | |||
export class DiscordAccount extends BaseEntity | |||
{ | |||
@PrimaryGeneratedColumn() | |||
id!: number; | |||
@Column({ length: 20 }) | |||
discordId!: string; | |||
@ManyToOne(() => User, user => user.discordAccounts) | |||
user!: User; | |||
@OneToMany(() => DiscordRequest, request => request.account) | |||
requests!: DiscordRequest[]; | |||
@CreateDateColumn() | |||
createdAt!: Date; | |||
} |
@ -1,17 +0,0 @@ | |||
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm"; | |||
@Entity() | |||
export class DiscordChannel extends BaseEntity | |||
{ | |||
@PrimaryGeneratedColumn() | |||
id!: number; | |||
@Column({ length: 20 }) | |||
channelId!: string; | |||
@Column({ length: 20 }) | |||
messageId!: string; | |||
@CreateDateColumn() | |||
createdAt!: Date; | |||
} |
@ -1,31 +0,0 @@ | |||
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm"; | |||
import { DiscordAccount } from "./DiscordAccount"; | |||
import { User } from "./User"; | |||
@Entity() | |||
export class DiscordLinkRequest extends BaseEntity | |||
{ | |||
@PrimaryGeneratedColumn() | |||
id!: number; | |||
@Column() | |||
discordId!: string; | |||
@Column() | |||
token!: string; | |||
@CreateDateColumn() | |||
createdAt!: Date; | |||
/** | |||
* Fulfill a link request for the given user | |||
*/ | |||
public async linkUser(user: User) { | |||
let account = new DiscordAccount(); | |||
account.discordId = this.discordId; | |||
account.user = user; | |||
let awaitSave = account.save(); | |||
await DiscordLinkRequest.delete({ id: this.id }); | |||
return await awaitSave; | |||
} | |||
} |
@ -1,26 +0,0 @@ | |||
import { BaseEntity, Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn } from "typeorm"; | |||
import { DiscordAccount } from "./DiscordAccount"; | |||
import { MovieTicket } from "./MovieTicket"; | |||
@Entity() | |||
export class DiscordRequest extends BaseEntity | |||
{ | |||
@PrimaryGeneratedColumn() | |||
id!: number; | |||
@Column({ length: 20 }) | |||
channelId!: string; | |||
@Column({ length: 20 }) | |||
messageId!: string; | |||
@CreateDateColumn() | |||
createdAt!: Date; | |||
@ManyToOne(() => DiscordAccount, account => account.requests) | |||
account!: DiscordAccount; | |||
@OneToOne(() => MovieTicket) | |||
@JoinColumn() | |||
ticket!: MovieTicket; | |||
} |
@ -1,23 +0,0 @@ | |||
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from "typeorm"; | |||
@Entity() | |||
export class MovieInfo extends BaseEntity | |||
{ | |||
@PrimaryGeneratedColumn() | |||
id!: number; | |||
@Column({ type: "text", nullable: true }) | |||
overview!: string | null; | |||
@Column({ type: "int", nullable: true }) | |||
runtime!: number | null; | |||
@Column({ type: "char", length: 10, nullable: true }) | |||
releaseDate!: string | null; | |||
@Column({ type: "varchar", length: 32, nullable: true }) | |||
backdropPath!: string | null; | |||
@Column({ type: "varchar", length: 32, nullable: true }) | |||
posterPath!: string | null; | |||
} |
@ -1,11 +0,0 @@ | |||
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; | |||
@Entity() | |||
export class MovieQuota extends BaseEntity | |||
{ | |||
@PrimaryGeneratedColumn() | |||
id!: number; | |||
@Column({ default: 5 }) | |||
moviesPerWeek!: number; | |||
} |
@ -1,93 +0,0 @@ | |||
import { IApiMovieDetails } from "@common/api_schema"; | |||
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity, ManyToOne, OneToMany, OneToOne, JoinColumn, CreateDateColumn, Not, IsNull } from "typeorm"; | |||
import { MovieInfo } from "./MovieInfo"; | |||
import { MovieTorrent } from "./MovieTorrent"; | |||
import { User } from "./User"; | |||
@Entity() | |||
export class MovieTicket extends BaseEntity | |||
{ | |||
@PrimaryGeneratedColumn() | |||
id!: number; | |||
@Column({ type: "int", nullable: true }) | |||
tmdbId!: number | null; | |||
@Column({ type: "varchar", length: 27, nullable: true }) | |||
imdbId!: string | null; | |||
@Column({ type: "varchar" }) | |||
title!: string; | |||
@Column({ type: "year", nullable: true }) | |||
year!: number | null; | |||
@CreateDateColumn() | |||
createdAt!: Date; | |||
@Column({ default: false }) | |||
isFulfilled!: boolean; | |||
@Column({ default: false }) | |||
isCanceled!: boolean; | |||
@Column({ default: false }) | |||
isStale!: boolean; | |||
@ManyToOne(() => User, user => user.movieTickets) | |||
user!: User; | |||
@OneToMany(() => MovieTorrent, torrent => torrent.movieTicket) | |||
torrents!: MovieTorrent[]; | |||
@OneToOne(() => MovieInfo, { nullable: true }) | |||
@JoinColumn() | |||
info!: MovieInfo | null; | |||
/** | |||
* @TODO This needs to check for fulfilled tickets too, but not there yet | |||
* Fetch all active ticket ID's | |||
*/ | |||
public static async activeTicketMap() { | |||
let tickets = await MovieTicket.find({ where: { tmdbId: Not(IsNull()), isCanceled: false } }); | |||
let result: {[tmdbId: number]: number} = {}; | |||
for (let ticket of tickets) { | |||
result[<number>ticket.tmdbId] = ticket.id; | |||
} | |||
return result; | |||
} | |||
/** | |||
* Insert a request via IMDb movie details | |||
*/ | |||
public static async requestImdb(user: User, imdbId: string, title: string, year: number) { | |||
let ticket = new MovieTicket(); | |||
ticket.imdbId = imdbId; | |||
ticket.title = title; | |||
ticket.year = year; | |||
ticket.user = user; | |||
return await ticket.save(); | |||
} | |||
/** | |||
* Insert a rquest via TMDb movie details | |||
*/ | |||
public static async requestTmdb(user: User, movie: IApiMovieDetails) { | |||
let info = new MovieInfo(); | |||
info.overview = movie.overview; | |||
info.posterPath = movie.posterPath; | |||
info.backdropPath = movie.backdropPath; | |||
info.releaseDate = movie.releaseDate; | |||
info.runtime = movie.runtime; | |||
await info.save(); | |||
let ticket = new MovieTicket(); | |||
ticket.tmdbId = movie.tmdbId; | |||
ticket.imdbId = movie.imdbId; | |||
ticket.title = movie.title; | |||
ticket.year = movie.releaseDate ? parseInt(movie.releaseDate.slice(0, 4)) : null; | |||
ticket.user = user; | |||
ticket.info = info; | |||
return await ticket.save(); | |||
} | |||
} |
@ -1,18 +0,0 @@ | |||
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity, ManyToOne } from "typeorm"; | |||
import { MovieTicket } from "./MovieTicket"; | |||
@Entity() | |||
export class MovieTorrent extends BaseEntity | |||
{ | |||
@PrimaryGeneratedColumn() | |||
id!: number; | |||
@Column() | |||
infoHash!: string; | |||
@Column() | |||
diskName!: string; | |||
@ManyToOne(() => MovieTicket, ticket => ticket.torrents) | |||
movieTicket!: MovieTicket; | |||
} |
@ -1,50 +0,0 @@ | |||
import Plex from "@lib/plex"; | |||
import { BaseEntity, Column, Entity, In, PrimaryColumn } from "typeorm"; | |||
@Entity() | |||
export class PlexMovie extends BaseEntity | |||
{ | |||
@PrimaryColumn({ length: 27 }) | |||
imdbId!: string; | |||
@Column({ type: "int", nullable: true, unique: true }) | |||
tmdbId!: number|null; | |||
@Column() | |||
plexKey!: number; | |||
/** | |||
* Check if a movie is on Plex given its TMDb ID | |||
*/ | |||
public static async findPlexKey(tmdbId: number|string) { | |||
return (await PlexMovie.findOne({ where: { tmdbId } }))?.plexKey ?? null; | |||
} | |||
/** | |||
* Get the set of IMDb IDs stored in the library | |||
*/ | |||
public static async imdbSet() { | |||
let rows = await PlexMovie.createQueryBuilder("plex_movie") | |||
.select("imdbId") | |||
.getRawMany(); | |||
return new Set(rows.map(row => <string>row.imdbId)); | |||
} | |||
/** | |||
* Insert a set of IMDb IDs into the database | |||
*/ | |||
public static async insertMovies(movies: { imdbId: string, plexKey: number }[]) { | |||
await PlexMovie.createQueryBuilder() | |||
.insert() | |||
.into("plex_movie") | |||
.values([...movies].map(({imdbId, plexKey}) => ({ imdbId, tmdbId: null, plexKey }))) | |||
.execute(); | |||
} | |||
/** | |||
* Remove the given set of IMDb IDs from the library | |||
*/ | |||
public static async removeImdbSet(imdbIds: Set<string>) { | |||
await PlexMovie.delete({ imdbId: In([...imdbIds]) }); | |||
} | |||
} |
@ -1,32 +0,0 @@ | |||
import { generateToken } from "@autoplex/utils"; | |||
import { randomBytes } from "crypto"; | |||
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from "typeorm"; | |||
@Entity() | |||
export class RegisterToken extends BaseEntity | |||
{ | |||
@PrimaryGeneratedColumn() | |||
id!: number; | |||
@Column() | |||
token!: string | |||
/** | |||
* Check if the provided token is valid | |||
*/ | |||
public static async isValid(token: string) { | |||
if (typeof token !== "string") { | |||
return false; | |||
} | |||
return Boolean(token) && await RegisterToken.count({token}) > 0; | |||
} | |||
/** | |||
* Create a new registration token and insert it into the database | |||
*/ | |||
public static async generate() { | |||
let token = new RegisterToken(); | |||
token.token = await generateToken(); | |||
return await token.save(); | |||
} | |||
} |
@ -1,113 +0,0 @@ | |||
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity, OneToMany, OneToOne, JoinColumn, CreateDateColumn, MoreThanOrEqual } from "typeorm"; | |||
import bcrypt from "bcrypt"; | |||
import { MovieTicket } from "./MovieTicket"; | |||
import { MovieQuota } from "./MovieQuota"; | |||
import { IApiMovie } from "@common/api_schema"; | |||
import { DiscordAccount } from "./DiscordAccount"; | |||
@Entity() | |||
export class User extends BaseEntity | |||
{ | |||
@PrimaryGeneratedColumn() | |||
id!: number; | |||
@Column() | |||
isAdmin!: boolean; | |||
@Column({ length: 50 }) | |||
name!: string; | |||
@Column({ length: 255 }) | |||
email!: string; | |||
@Column({ type: "char", length: 60 }) | |||
password!: string; | |||
@CreateDateColumn() | |||
createdAt!: Date; | |||
@OneToOne(() => MovieQuota, { nullable: true }) | |||
@JoinColumn() | |||
quota!: MovieQuota | null; | |||
@OneToMany(() => User, user => user.movieTickets) | |||
movieTickets!: MovieTicket[]; | |||
@OneToMany(() => DiscordAccount, account => account.user) | |||
discordAccounts!: DiscordAccount[]; | |||
/** | |||
* Authenticate a user and return an auth token upon success | |||
*/ | |||
public static async authenticate(email: string, password: string) { | |||
let user = <User>await User.findOne({ email }); | |||
if (user === undefined || !(await bcrypt.compare(password, user.password))) { | |||
return null; | |||
} | |||
return user; | |||
} | |||
/** | |||
* Create a new user | |||
*/ | |||
public static async createUser(name: string, email: string, password: string, quota: number|null = 5) { | |||
let user = new User(); | |||
user.isAdmin = false; | |||
user.name = name; | |||
user.email = email; | |||
user.password = await bcrypt.hash(password, 8); | |||
// Create a quota if necessary | |||
if (quota !== null) { | |||
user.quota = new MovieQuota; | |||
user.quota.moviesPerWeek = quota; | |||
await user.quota.save(); | |||
} | |||
return await user.save(); | |||
} | |||
/** | |||
* Determine the user's available quota | |||
*/ | |||
public async availableQuota() { | |||
let quota = await this.fetchQuota(); | |||
if (quota === null) { | |||
return null; | |||
} | |||
let oneWeekAgo = new Date(Date.now() - 1000*60*60*24*7); | |||
let numTicketsThisWeek = await MovieTicket.count({ | |||
user: this, | |||
createdAt: MoreThanOrEqual(oneWeekAgo), | |||
isCanceled: false | |||
}); | |||
return quota.moviesPerWeek - numTicketsThisWeek; | |||
} | |||
/** | |||
* Get the user's quota, fetching it if undefined | |||
*/ | |||
public async fetchQuota() { | |||
if (this.quota !== undefined) { | |||
return this.quota; | |||
} | |||
let user = <User>await User.findOne(this.id, { relations: ["quota"] }); | |||
return user.quota; | |||
} | |||
/** | |||
* Fetch active movie tickets for this user | |||
*/ | |||
public async activeMovieTickets() { | |||
let tickets = await MovieTicket.find({ | |||
where: { user: this, isCanceled: false, isFulfilled: false }, | |||
relations: ["info"] | |||
}); | |||
return tickets.map(ticket => <IApiMovie>({ | |||
plexLink : null, | |||
posterPath : ticket.info?.posterPath, | |||
releaseDate: ticket.info?.releaseDate, | |||
ticketId : ticket.id, | |||
title : ticket.title, | |||
tmdbId : ticket.tmdbId | |||
})); | |||
} | |||
} |
@ -1,11 +0,0 @@ | |||
export * from "./DiscordAccount"; | |||
export * from "./DiscordChannel"; | |||
export * from "./DiscordLinkRequest"; | |||
export * from "./DiscordRequest"; | |||
export * from "./MovieInfo"; | |||
export * from "./MovieQuota"; | |||
export * from "./MovieTicket"; | |||
export * from "./MovieTorrent"; | |||
export * from "./PlexMovie"; | |||
export * from "./RegisterToken"; | |||
export * from "./User"; |
@ -1,22 +0,0 @@ | |||
import * as entities from "./entities"; | |||
import { readFile } from "fs/promises"; | |||
import { createConnection } from "typeorm"; | |||
export default async function connectToDatabase() { | |||
// Fetch the database password from the secret file | |||
let password = (await readFile(<string>process.env["DB_PASSWORD_FILE"])).toString().trim(); | |||
// Create the database connection | |||
await createConnection({ | |||
type : <"mysql" | "mariadb">process.env["DB_TYPE"], | |||
host : process.env["DB_HOST"], | |||
port : parseInt(<string>process.env["DB_PORT"]), | |||
username : process.env["DB_USER"], | |||
password : password, | |||
database : process.env["DB_DATABASE"], | |||
// synchronize: process.env["NODE_ENV"] != "production", | |||
synchronize: true, // Seems stable enough for my liking | |||
entities : Object.values(entities), | |||
migrations : ["src/migrations/*.ts"] | |||
}); | |||
} |