/**
* ..... .. s .
* .H8888888h. ~-. < .z@8"` :8 @88> oec : ..
* 888888888888x `> .u . !@88E .88 %8P @88888 @L
* X~ `?888888hx~ .d88B :@8c u '888E u :888ooo . 8"*88% 9888i .dL
* ' x8.^"*88*" ="8888f8888r us888u. 888E u@8NL -*8888888 .@88u 8b. `Y888k:*888.
* `-:- X8888x 4888>'88" .@88 "8888" 888E`"88*" 8888 ''888E` u888888> 888E 888I
* 488888> 4888> ' 9888 9888 888E .dN. 8888 888E 8888R 888E 888I
* .. `"88* 4888> 9888 9888 888E~8888 8888 888E 8888P 888E 888I
* x88888nX" . .d888L .+ 9888 9888 888E '888& .8888Lu= 888E *888> 888E 888I
* !"*8888888n.. : ^"8888*" 9888 9888 888E 9888. ^%888* 888& 4888 x888N><888'
* ' "*88888888* "Y" "888*""888" '"888*" 4888" 'Y" R888" '888 "88" 888
* ^"***"` ^Y" ^Y' "" "" "" 88R 88F
* 88> 98"
* is a desktop app for trakt.tv 48 ./"
* created by @CodingBobby and @Bumbleboss in 2019 '8 ~`
* using the trakt api with the trakt.js library
* in an electron framework
*/
// Uncomment this line for deployment!
// process.env.NODE_ENV = 'production'
let initTime = Date.now()
// file stuff
const fs = require('fs-extra')
const path = require('path')
/**
* path to /src/ that is used as base paths in other files
* @type {String}
*/
const APP_PATH = __dirname
process.env.APP_PATH = APP_PATH
// electron stuff
const electron = require('electron')
const windowStateKeeper = require('electron-window-state')
const {
app,
BrowserWindow,
Menu,
shell,
clipboard,
dialog,
ipcMain
} = electron
// api stuff
const Trakt = require('trakt.tv')
const Fanart = require('fanart.tv')
const TvDB = require('node-tvdb')
const TmDB = require('moviedb-promise')
// request stuff
const request = require('request')
// helper imports
const {
debugLog, inRange, shadeHexColor, clone, ipcChannels
} = require('./modules/helper.js')
debugLog('Welcome to', '~|~ /? /\ /< ~|~ | /= `/')
// file setup imports
const {
initFileStructure, getAPIKeys,
saveConfig, readConfig, resetConfig,
removeCacheFiles
} = require('./modules/app/files.js')
;(async () => {
const PATHS = await initFileStructure()
global.debugLog = debugLog
let apiKeys
try {
apiKeys = getAPIKeys()
} catch(err) {
debugLog('error', 'could not get API keys', err)
}
const API_KEYS = clone(apiKeys)
// configuration and boolean checks that we need frequently
// the config file will be used to save preferences the user can change
// (like darkmode, behavior etc.)
global.config = readConfig()
let user = global.config.user
// defining global variables that can be accessed from other scripts
global.openExternal = shell.openExternal
global.darwin = process.platform == 'darwin'
// Comment out these lines when in production! Used as helpers to move around the app from the command line.
global.loadDashboard = loadDashboard
global.loadLogin = loadLogin
// these are the api globals
global.trakt
global.fanart
global.tvdb
global.tmdb
let window = null
const traktOptions = {
client_id: API_KEYS.trakt_id,
client_secret: API_KEYS.trakt_secret
}
if(process.env.NODE_ENV !== 'production') {
traktOptions.debug = true
}
// here we set some options we need later
const windowOptions = {
minWidth: 800,
minHeight: 500,
width: 900,
height: 750,
useContentSize: true,
titleBarStyle: 'hidden',
backgroundColor: '#242424',
title: 'Traktify',
icon: global.darwin ? './assets/icons/trakt/trakt.icns'
: './assets/icons/trakt/tract.ico',
show: false,
center: true,
webPreferences: {
experimentalFeatures: true
}
}
// here we create a template for the main menu, to get the right shortcut, we check if we're running on darwin
let menuTemplate = [{
label: 'App',
submenu: [{
label: 'About',
click() {
shell.openExternal('https://github.com/CodingBobby/traktify')
}
}, {
label: 'Quit Traktify',
accelerator: global.darwin ? 'Command+Q'
: 'Ctrl+Q',
click() {
app.quit()
}
}, {
type: 'separator'
}, {
label: 'Reset Traktify',
click() {
dialog.showMessageBox({
type: 'question',
title: 'Reset Traktify',
message: 'Are you sure? This removes all data from Traktify and you have to login again.',
buttons: ['alright', 'hell no'],
defaultId: 1,
normalizeAccessKeys: false
}, button => {
if(button == 0) {
global.config = resetConfig()
disconnect()
}
})
}
}]
}]
if(global.darwin) {
menuTemplate[0].submenu.splice(1, 0, {
label: 'Hide Traktify',
accelerator: 'Command+H',
click() {
// this is only available on darwin
app.hide()
}
})
}
// if the app is in development mode, these menu items will be pushed to the menu template
if(process.env.NODE_ENV !== 'production') {
menuTemplate.push({
label: 'Dev Tools',
submenu: [{
label: 'Toggle Dev Tools',
accelerator: global.darwin ? 'Command+I'
: 'Ctrl+I',
click(item, focusedWindow){
focusedWindow.toggleDevTools()
}
}, {
label: 'Reload App',
accelerator: global.darwin ? 'Command+R'
: 'Ctrl+R',
role: 'reload'
}]
})
}
const mainMenu = Menu.buildFromTemplate(menuTemplate)
// This function builds the app window, shows the correct page and handles window.on() events
function build() {
debugLog('app', 'now building')
let mainWindowState = windowStateKeeper({
defaultWidth: 900,
defaultHeight: 750
})
let settings = getSettings('app')
if(settings['keep window state'].status) {
debugLog('app', 'keeping window state changes')
windowOptions.x = mainWindowState.x
windowOptions.y = mainWindowState.y
windowOptions.width = mainWindowState.width
windowOptions.height = mainWindowState.height
}
if(settings['discord rpc'].status) {
debugLog('app', 'discord rpc enabled')
}
window = new BrowserWindow(windowOptions)
Menu.setApplicationMenu(mainMenu)
if(getSettings('app')['keep window state'].status) {
mainWindowState.manage(window)
}
// These now try to connect to the APIs we are using
try {
debugLog('api', 'creating trakt instance')
global.trakt = new Trakt(traktOptions)
} catch(err) {
debugLog('error', 'trakt authentication', new Error().stack)
}
try {
debugLog('api', 'creating fanart instance')
global.fanart = new Fanart(API_KEYS.fanart_key)
} catch(err) {
debugLog('error', 'fanart authentication', new Error().stack)
}
try {
debugLog('api', 'creating tvdb instance')
global.tvdb = new TvDB(API_KEYS.tvdb_key)
} catch(err) {
debugLog('error', 'tvdb authentication', new Error().stack)
}
try {
debugLog('api', 'creating tmdb instance')
global.tmdb = new TmDB(API_KEYS.tmdb_key)
} catch(err) {
debugLog('error', 'tmdb authentication', new Error().stack)
}
// show the window when the page is built
window.once('ready-to-show', () => {
debugLog('window', 'ready')
window.show()
})
debugLog('init time', (Date.now() - initTime)+'ms')
// Now we launch the app renderer
launchApp()
// EVENTS
// if the window gets closed, the app will quit
window.on('closed', () => {
debugLog('window', 'closed')
win = null
})
window.on('restore', () => {
debugLog('window', 'restored')
window.focus()
})
}
// here we finally build the app
app.on('ready', build)
// this quits the whole app
app.on('window-all-closed', () => {
debugLog('app', 'now closing')
app.quit()
})
// This launcher checks if the user is possibly logged in already. If so, we try to login with the existing credentials. If not, we go directly to the login screen.
function launchApp() {
if(user.trakt.auth) {
debugLog('login', 'connecting existing user to trakt')
tryLogin()
} else {
debugLog('login', 'no user found')
loadLogin()
}
}
function tryLogin() {
// First, we show the loading screen to tell the user that something is happening. Eventually, when all loading processes are finished, it will be closed again to reveal the dashboard.
loadLoadingScreen()
// wait until loading screen is fully loaded
ipcMain.once('loading-screen', (event, data) => {
if(data === 'loaded') {
debugLog('loading', 'can start now')
global.trakt.import_token(user.trakt.auth).then(() => {
global.trakt.refresh_token(user.trakt.auth).then(async newAuth => {
user.trakt.auth = newAuth
user.trakt.status = true
saveConfig(global.config)
debugLog('login', 'success')
// track user stats for traktify analytics
let userSettings = await trakt.users.settings().then(res => res)
request(`https://traktify-server.herokuapp.com/stats?username=${userSettings.user.username}`, {
json: true
}, (err, res, body) => {
if (err) debugLog('error', err)
if (body.data) debugLog('user authentications', body.data.requests)
else debugLog('error', 'traktify server error', new Error().stack)
})
event.returnValue = 'start'
// After loadingHandler is finished with everything, the dashboard is opened
loadingHandler().then(() => {
loadDashboard()
})
}).catch(err => {
if(err) {
user.trakt.auth = false
user.trakt.status = false
saveConfig(global.config)
debugLog('login failed', err)
removeCacheFiles()
loadLogin()
}
})
})
}
})
}
function authenticate() {
return global.trakt.get_codes().then(poll => {
clipboard.writeText(poll.user_code) // give the user the code
global.codeToClipboard = function codeToClipboard() {
// provides the user the option to get the code again
clipboard.writeText(poll.user_code)
}
shell.openExternal(poll.verification_url)
return global.trakt.poll_access(poll)
}).then(auth => {
debugLog('login', 'trakt user signed in')
global.trakt.import_token(auth)
user.trakt.auth = auth
user.trakt.status = true
saveConfig(global.config)
// going back to the app and heading into dashboard
window.focus()
tryLogin() // confirm login credentials for extra safety and start loading
return true
}).catch(err => {
// The failing login probably won't happen because the trakt login page would already throw the error. This exist just as a fallback.
if(err) {
debugLog('error', 'login failed')
user.trakt.auth = false
user.trakt.status = false
saveConfig(global.config)
window.focus()
loadLogin()
}
})
}
global.authenticate = authenticate
function disconnect() {
global.trakt.revoke_token()
global.config = resetConfig()
removeCacheFiles()
loadLogin()
}
global.disconnect = disconnect
// These functions do nothing but load a render page
function loadLogin() {
window.loadFile(path.join(APP_PATH, 'pages/login/index.html'))
}
function loadDashboard() {
window.loadFile(path.join(APP_PATH, 'pages/dashboard/index.html'))
}
function loadLoadingScreen() {
window.loadFile(path.join(APP_PATH, 'pages/loading/index.html'))
}
function loadingHandler() {
let loadingTime = Date.now()
return new Promise((resolve, reject) => {
// waiting for the loading to be done
ipcMain.once('loading-screen', (event, data) => {
if(data === 'done') {
debugLog('loading time', Date.now()-loadingTime+'ms')
resolve()
}
})
})
}
// The getSetting and setSetting functions are used by the settings panel to get and apply custom settings. defaultAll can reset these settings by replacing the current ones with those from the default file def_config.json
function getSettings(scope) {
let settings = global.config.client.settings
if(settings.hasOwnProperty(scope)) {
return settings[scope]
} else {
console.error('Invalid scope at getSetting()')
}
}
global.getSettings = getSettings
function setSetting(scope, settingOption, newStatus) {
let settings = global.config.client.settings[scope]
let setting = settings[settingOption]
if(newStatus == 'default') {
setting.status = setting.default
} else {
switch(setting.type) {
case 'select': {
if(setting.options.hasOwnProperty(newStatus)) {
setting.status = newStatus
}
break
}
case 'range': {
if(inRange(newStatus, setting.range)) {
setting.status = newStatus
}
break
}
case 'toggle': {
if(typeof newStatus == 'boolean') {
setting.status = newStatus
}
break
}
default: { break }
}
}
saveConfig(global.config)
}
global.setSetting = setSetting
function defaultAll(scope) {
let settings = getSettings(scope)
for(let s in settings) {
setSetting(scope, s, 'default')
}
}
global.defaultAll = defaultAll
// This applies the saved settings to the master css file. The currently loaded HTML must handle the incoming message via the proper IPC helpers.
function updateApp() {
let settings = getSettings('app')
for(let s in settings) {
debugLog('updating setting', s)
let setting = settings[s]
// these are only the settings that can be changed in realtime
switch(s) {
case 'accent color': {
let value = setting.options[setting.status].value
window.webContents.send('modify-root', {
name: '--accent_color',
value: value
})
let value_dark = shadeHexColor(value, -20)
window.webContents.send('modify-root', {
name: '--accent_color_d',
value: value_dark
})
break
}
case 'background image': {
let value = setting.options[setting.status].value
window.webContents.send('modify-root', {
name: '--background_image',
value: `url('./${value}')`
})
break
}
case 'background opacity': {
let value = setting.status
window.webContents.send('modify-root', {
name: '--background_opacity',
value: value/100
})
break
}
default: { break }
}
}
}
global.updateApp = updateApp
// Quits the app and reopens it automatically. This is used to apply settings which would interfer with this this app.js file.
function relaunchApp() {
app.relaunch()
app.quit(0)
}
global.relaunchApp = relaunchApp
// init listeners
require('./modules/app/listener.js')
//:::: LOG LISTENER ::::\\
ipcMain.on('log', (event, details) => {
/** details:
* action,
* log
*/
// using the helper here to make calls from the main possible as well
ipcChannels['log'](details)
})
// dev cheat commands
ipcMain.on('run', (event, details) => {
/** details:
* type,
* str
*/
debugLog('!cheat', `triggered ${details.str} via ${details.type}`)
switch(details.type) {
case 'functionName': {
eval(details.str)
break
}
}
})
/**
*
* @param {'small'|'big'} size
*/
function resizeWindow(size) {
switch(size) {
case 'small': {
window.setSize(300, 300, true)
break
}
case 'big': {
window.setSize(900, 750, true)
break
}
}
}
})()