├── .bowerrc ├── .gitattributes ├── .gitignore ├── Gruntfile.js ├── README.md ├── TobyLauncher ├── TobyLauncher.sln └── TobyLauncher │ ├── App.config │ ├── Launcher.cs │ ├── Properties │ └── AssemblyInfo.cs │ ├── TobyLauncher.csproj │ ├── packages.config │ └── toby.ico ├── bower.json ├── data └── data.json ├── definitions ├── README.md └── youtube-search.d.ts ├── index.html ├── package.json ├── public ├── images │ ├── toby.ico │ └── toby.png └── stylesheets │ ├── app.css │ └── main.css ├── screenshots ├── toby-main.png ├── toby-manage.png ├── toby-recently-played.png ├── toby-server-log.png ├── toby-video-list-slim-grid.png ├── toby-video-list.png └── toby-video-playback.png ├── src ├── api.ts ├── config.ts ├── data.ts ├── db.ts ├── electron.ts ├── infrastructure.ts ├── platform.ts ├── react-components │ ├── command-input-ui.tsx │ ├── dropdown-ui.tsx │ ├── infrastructure.ts │ ├── server-log-ui.tsx │ ├── toby-ui.tsx │ ├── version-ui.tsx │ ├── video-list-grid-ui.tsx │ ├── video-list-ui.tsx │ └── youtube-ui.tsx ├── searchCache.ts └── server.ts ├── tsconfig.json ├── tslint.json ├── typings └── electron.d.ts ├── views ├── error.hbs ├── index.hbs └── layout.hbs ├── webpack.config.js └── yarn.lock /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "public/components/" 3 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | youtube-api-key.ts 2 | .tscache/ 3 | .baseDir.ts 4 | data/videoDB 5 | build/ 6 | bower_components/ 7 | public/components/ 8 | public/scripts/ 9 | node_modules/ 10 | browser*/ 11 | nwjs/ 12 | electron/ 13 | node.exe 14 | node.lib 15 | npm-debug.log 16 | .vscode/ 17 | .vs/ 18 | bin/ 19 | packages/ 20 | obj/ 21 | debug.log 22 | *.exe 23 | *.dll 24 | *.csproj.user -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | grunt.initConfig({ 3 | copy: { 4 | main: { 5 | files: [ 6 | { 7 | src: ["index.html"], 8 | dest: "build/" 9 | } 10 | ] 11 | } 12 | }, 13 | ts: { 14 | default: { 15 | src: ["src/*.ts"], 16 | outDir: "build", 17 | options: { 18 | rootDir: "src", 19 | sourceMap: true, 20 | moduleResolution: "node", 21 | target: "es6", 22 | module: "commonjs" 23 | } 24 | } 25 | }, 26 | tslint: { 27 | files: { 28 | src: ["src/*.ts*", "src/react-components/*.ts*", "definitions/*.ts*"] 29 | }, 30 | options: { 31 | force: false, 32 | fix: false 33 | } 34 | } 35 | }); 36 | 37 | grunt.loadNpmTasks("grunt-ts"); 38 | grunt.loadNpmTasks("grunt-contrib-copy"); 39 | grunt.loadNpmTasks("grunt-tslint"); 40 | 41 | grunt.registerTask("default", ["ts", "copy", "tslint"]); 42 | }; 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Toby 2 | 3 | **NOTE: A rewrite of this project is underway using Blazor and can be found 4 | [here](https://github.com/frankhale/toby-blazor)** 5 | 6 | Toby is a simple YouTube player for the desktop. 7 | 8 | ### Screenshots 9 | 10 | Toby In Action: 11 | 12 | ![Toby In Action](screenshots/toby-video-playback.png) 13 | 14 | Toby Main UI: 15 | 16 | ![Toby Main UI](screenshots/toby-main.png) 17 | 18 | Toby Video List: 19 | 20 | ![Toby Video List](screenshots/toby-video-list.png) 21 | 22 | Toby Video List (Slim Grid) 23 | 24 | ![Toby Video List (Slim Grid)](screenshots/toby-video-list-slim-grid.png) 25 | 26 | Toby Recently Played: 27 | 28 | ![Toby Recently Played](screenshots/toby-recently-played.png) 29 | 30 | Toby Manage Videos: 31 | 32 | ![Toby Manage Videos](screenshots/toby-manage.png) 33 | 34 | Toby Server Log: 35 | 36 | ![Toby Server Log](screenshots/toby-server-log.png) 37 | 38 | ### Architecture 39 | 40 | The old Toby architecture was geared towards an Electron deployment and I loaded 41 | all the code from the file system. The new architecture puts Toby behind an 42 | Express web application that is spawned from a regular Node process so that more 43 | deployment scenarios are possible. 44 | 45 | Having Toby behind an Express app makes it fairly trivial to deploy to NW.js, 46 | Electron and support a regular web browser. 47 | 48 | Toby is meant as a personal application running on a personal computer and it's 49 | web API is not password protected in any way and there has been no attempt to 50 | protect the data Toby collects. Toby only cares about a few things, namely 51 | YouTube video titles, YouTube video IDs and the groups you decide to store your 52 | favorite videos in. 53 | 54 | ### Running The Latest Code 55 | 56 | Clone the code using `git` and then add a folder named `browsers` with a copy of 57 | `electron` and/or `nwjs`. We'll use one of these to run Toby. 58 | 59 | #### Dependencies 60 | 61 | - Node : [http://nodejs.org](http://nodejs.org) 62 | - Grunt : [http://gruntjs.com](http://gruntjs.com) 63 | - Bower : [http://bower.io/](http://bower.io/) 64 | - Webpack : [https://webpack.github.io/](https://webpack.github.io/) 65 | - Typescript : [http://typescriptlang.org/](http://typescriptlang.org/) 66 | 67 | ## You Just Need One Of The Following: 68 | 69 | - Electron: [https://electronjs.org/](https://electronjs.org/) 70 | - NW.js: [http://nwjs.io/](http://nwjs.io/) 71 | 72 | Depending on what platform you want to run Toby in (Electron or NW.js) you'll 73 | need to make sure the main property in package.json is set accordingly: 74 | 75 | ##### NW.js 76 | 77 | ``` 78 | main: "./build/index.html" 79 | ``` 80 | 81 | ##### Electron 82 | 83 | ``` 84 | main: "./build/electron.js" 85 | ``` 86 | 87 | In order to run Toby you'll need to download the dependencies and build the 88 | source code. Open a terminal to the source code repository and run the following 89 | commands. 90 | 91 | #### Install Dependencies 92 | 93 | ``` 94 | npm install -g webpack webpack-cli typescript grunt bower 95 | npm install 96 | bower install 97 | ``` 98 | 99 | #### Building the Source Code 100 | 101 | NOTE: You will need to supply your own YouTube API key. This needs to be placed 102 | in an environment variable called `YOUTUBE_API_KEY`. You will need a Google 103 | account to obtain one. Go [here (https://console.developers.google.com) to get 104 | an API key. 105 | 106 | The server needs to be built using Grunt. 107 | 108 | ``` 109 | grunt 110 | ``` 111 | 112 | The front end needs to be build using Webpack. 113 | 114 | ``` 115 | webpack 116 | ``` 117 | 118 | Assuming all dependencies are downloaded and the source code has been compiled 119 | perform the following from a command line at the root of the Toby code 120 | repository: 121 | 122 | **NOTE**: `main` will need to be updated in `package.json` to point to the 123 | correct starting point for your deployment scenario. If you are using Electron 124 | it will need to be set to `build\electron.js` or if you are using NW.js it'll 125 | need to be set to `build\index.html`. It should also be noted that the 126 | index.html contained in the root of the Toby repository will be copied to the 127 | build folder and used from there. 128 | 129 | #### Running in NW.js 130 | 131 | ``` 132 | browsers\nwjs\nw.exe . 133 | ``` 134 | 135 | **NOTE**: You may want to replace the `ffmpeg.dll` that ships with NW.js with a more 136 | capable one from [https://github.com/iteufel/nwjs-ffmpeg-prebuilt/releases](https://github.com/iteufel/nwjs-ffmpeg-prebuilt/releases). The `ffmpeg.dll` 137 | that ships with NW.js is crippled and won't play many of the YouTube videos you 138 | most likely will want to play. 139 | 140 | #### Running in Electron 141 | 142 | ``` 143 | browsers\electron\electron . 144 | ``` 145 | 146 | #### Running in a Browser 147 | 148 | Start the server up: 149 | 150 | ``` 151 | node.exe build\server.js 152 | ``` 153 | 154 | Then open a browser to `http://127.0.0.1:62374` 155 | 156 | ### Running using the Toby Launcher 157 | 158 | I've wrote a rudimentary launcher in C# .NET to assist with launching Toby 159 | easily. By default if you run the launcher without command line args it will run 160 | Toby using NW.js. There is only one command line option at this time. 161 | 162 | After building the launcher copy the TobyLauncher.exe, NDesk.Options.dll and 163 | Newtonsoft.Json.dll files to the root of the Toby repository. 164 | 165 | - Command Line Options: 166 | - /p `[nw, electron, web]` 167 | 168 | Examples: 169 | 170 | Launching Toby in a web browser: `TobyLauncher.exe /p web` 171 | Launching Toby in Electron: `TobyLauncher.exe /p electron` 172 | 173 | NOTE: The launcher is crude and there is not enough error checking yet. Things 174 | will likely go wrong if Toby is not set up correctly as stated above. 175 | 176 | ### Usage 177 | 178 | **Important Key Combos:** 179 | 180 | F1 - Toggles server log 181 | F11 - Toggles fullscreen 182 | 183 | In addition to keyboard shortcuts there are commands that can be typed into the 184 | search box that will perform various things. 185 | 186 | Here is a list (there will be additional ones added soon): 187 | 188 | - `[name hint]` : Lists locally saved videos based on the [name hint] 189 | - `[search term]` : Searches YouTube for the [search term] 190 | - `/local [search term]` : Searches for locally saved videos 191 | - `/g [group name]` : Lists the videos for the [group name] 192 | - `/list-all` : List all videos contained in the database 193 | - `/history` : Lists the recently played videos 194 | - `/rp` or `/recently-played` : List last 30 recently played videos 195 | - `/rps` or `/recently-played-search` : Search recently played videos 196 | - `/manage` : Manage what groups videos are in and also provide ability to 197 | delete videos 198 | - `/archive` : Export the contents of the database to the data.txt file 199 | - `/gv` or `/grid-view` - Toggle slim grid view for search results 200 | - `/dv` or `/default-view` - Toggle default view for search results 201 | - `/clear` : Clears search results 202 | - `/monochrome` : (NW.js/Electron only) Short cut to set the monochrome video 203 | filter and thumbnails in search results 204 | - `/saturate` : (NW.js/Electron only) Short cut to set the saturated video 205 | filter and thumbnails in search results 206 | - `/sepia` : (NW.js/Electron only) Short cut to set the sepia video filter and 207 | thumbnails in search results 208 | - `/normal` : (NW.js/Electron only) Short cut to set the normal video filter and 209 | thumbnails in search results 210 | - `/filter monochrome` : (NW.js/Electron only) Short cut to set the monochrome 211 | video filter and thumbnails in search results 212 | - `/filter saturate` : (NW.js/Electron only) Short cut to set the saturated 213 | video filter and thumbnails in search results 214 | - `/filter sepia` : (NW.js/Electron only) Short cut to set the sepia video 215 | filter and thumbnails in search results 216 | 217 | **NOTE**: You can refer to /src/toby-ui.tsx for the various short cuts available 218 | for these commands. 219 | 220 | ### Wait, I used NW.js and some YouTube videos won't play 221 | 222 | The FFMPEG library that ships with NW.js is less capable than the one that ships 223 | with Electron. The short answer is just copy the FFMPEG library from an Electron 224 | release replace the one that ships with NW.js. I've been doing this for a long 225 | time and it works well for me (on Windows). 226 | 227 | The longer answer is you can compile your own FFMPEG library with the support 228 | you and there are a lot of resources already out there to handle this scenario. 229 | 230 | **NOTE**: This technique does not work with NW.js 0.20.0-beta1 as the FFMPEG 231 | seems to be different than one that ships with Electron. 232 | 233 | Looks like there are some alternate FFMPEG builds available which can take care 234 | of this: [https://github.com/iteufel/nwjs-ffmpeg-prebuilt/releases](https://github.com/iteufel/nwjs-ffmpeg-prebuilt/releases) 235 | 236 | ### Features TODO 237 | 238 | - Usage info from within the app 239 | 240 | ### Updating the data file 241 | 242 | I've removed the ordinary data file as it was too cumbersome to get the parser 243 | correct. I've decided to just define some basic starting video data in the 244 | following code file `/src/data.ts`. If you are building from source feel free to 245 | edit this to your liking. If at anytime you edit this file and run Toby it will 246 | update your database importing any new videos you put there. 247 | 248 | **NOTE**: Although it hasn't been done yet it'd be trivial to replace this with 249 | JSON data loaded from the filesystem. 250 | 251 | ## Author(s) 252 | 253 | Frank Hale <frankhale@gmail.com> 254 | 24 November 2019 255 | 256 | ## License 257 | 258 | GNU GPL v3 - see [LICENSE](LICENSE) 259 | -------------------------------------------------------------------------------- /TobyLauncher/TobyLauncher.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26403.7 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TobyLauncher", "TobyLauncher\TobyLauncher.csproj", "{E34D48A1-7101-4DF8-8814-7D5AA400E6C6}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {E34D48A1-7101-4DF8-8814-7D5AA400E6C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {E34D48A1-7101-4DF8-8814-7D5AA400E6C6}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {E34D48A1-7101-4DF8-8814-7D5AA400E6C6}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {E34D48A1-7101-4DF8-8814-7D5AA400E6C6}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | EndGlobal 23 | -------------------------------------------------------------------------------- /TobyLauncher/TobyLauncher/App.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /TobyLauncher/TobyLauncher/Launcher.cs: -------------------------------------------------------------------------------- 1 | using NDesk.Options; 2 | using Newtonsoft.Json; 3 | using System.Diagnostics; 4 | using System.IO; 5 | 6 | // This is primitive and there is not enough error checking yet. This was done as a quick and dirty proof of concept 7 | // so that I can prepare for a release that will be easy to use. 8 | 9 | namespace TobyLauncher 10 | { 11 | class Launcher 12 | { 13 | static void Main(string[] args) 14 | { 15 | var json = ""; 16 | using (var sr = new StreamReader("package.json")) 17 | { 18 | json = sr.ReadToEnd(); 19 | } 20 | 21 | dynamic pkgJSON = JsonConvert.DeserializeObject(json); 22 | string platform = "nw"; 23 | var opts = new OptionSet() { 24 | { "p|platform=", "The platform to use for running Toby (nw, electron, web)", v => platform = v } 25 | }; 26 | 27 | opts.Parse(args); 28 | 29 | switch(platform) 30 | { 31 | case "nw": 32 | pkgJSON.main = "./build/index.html"; 33 | using (StreamWriter outputFile = new StreamWriter("package.json")) 34 | { 35 | outputFile.WriteLine(JsonConvert.SerializeObject(pkgJSON, Formatting.Indented)); 36 | } 37 | Process.Start(@".\browsers\nw\nw.exe", "."); 38 | break; 39 | case "electron": 40 | pkgJSON.main = "./build/electron.js"; 41 | using (StreamWriter outputFile = new StreamWriter("package.json")) 42 | { 43 | outputFile.WriteLine(JsonConvert.SerializeObject(pkgJSON, Formatting.Indented)); 44 | } 45 | Process.Start(@".\browsers\electron\electron.exe", "."); 46 | break; 47 | case "web": 48 | // Up in the air about this, I want to suppress the window but if I do that you'd have to kill the Node process in the 49 | // task manager if you wanted to quit Toby. 50 | Process.Start(new ProcessStartInfo() 51 | { 52 | FileName = @".\node", 53 | Arguments = "./build/server.js", 54 | //CreateNoWindow = true 55 | }); 56 | Process.Start("http://localhost:62374"); 57 | break; 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /TobyLauncher/TobyLauncher/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | 5 | // General Information about an assembly is controlled through the following 6 | // set of attributes. Change these attribute values to modify the information 7 | // associated with an assembly. 8 | [assembly: AssemblyTitle("TobyLauncher")] 9 | [assembly: AssemblyDescription("")] 10 | [assembly: AssemblyConfiguration("")] 11 | [assembly: AssemblyCompany("")] 12 | [assembly: AssemblyProduct("TobyLauncher")] 13 | [assembly: AssemblyCopyright("Copyright © 2017")] 14 | [assembly: AssemblyTrademark("")] 15 | [assembly: AssemblyCulture("")] 16 | 17 | // Setting ComVisible to false makes the types in this assembly not visible 18 | // to COM components. If you need to access a type in this assembly from 19 | // COM, set the ComVisible attribute to true on that type. 20 | [assembly: ComVisible(false)] 21 | 22 | // The following GUID is for the ID of the typelib if this project is exposed to COM 23 | [assembly: Guid("e34d48a1-7101-4df8-8814-7d5aa400e6c6")] 24 | 25 | // Version information for an assembly consists of the following four values: 26 | // 27 | // Major Version 28 | // Minor Version 29 | // Build Number 30 | // Revision 31 | // 32 | // You can specify all the values or you can default the Build and Revision Numbers 33 | // by using the '*' as shown below: 34 | // [assembly: AssemblyVersion("1.0.*")] 35 | [assembly: AssemblyVersion("1.0.0.0")] 36 | [assembly: AssemblyFileVersion("1.0.0.0")] 37 | -------------------------------------------------------------------------------- /TobyLauncher/TobyLauncher/TobyLauncher.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Debug 6 | AnyCPU 7 | {E34D48A1-7101-4DF8-8814-7D5AA400E6C6} 8 | WinExe 9 | TobyLauncher 10 | TobyLauncher 11 | v4.6.2 12 | 512 13 | true 14 | publish\ 15 | true 16 | Disk 17 | false 18 | Foreground 19 | 7 20 | Days 21 | false 22 | false 23 | true 24 | 0 25 | 1.0.0.%2a 26 | false 27 | false 28 | true 29 | 30 | 31 | AnyCPU 32 | true 33 | full 34 | false 35 | bin\Debug\ 36 | DEBUG;TRACE 37 | prompt 38 | 4 39 | 40 | 41 | AnyCPU 42 | pdbonly 43 | true 44 | bin\Release\ 45 | TRACE 46 | prompt 47 | 4 48 | 49 | 50 | 51 | 52 | 53 | toby.ico 54 | 55 | 56 | 57 | ..\packages\NDesk.Options.0.2.1\lib\NDesk.Options.dll 58 | 59 | 60 | ..\packages\Newtonsoft.Json.10.0.2\lib\net45\Newtonsoft.Json.dll 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | False 82 | Microsoft .NET Framework 4.6.2 %28x86 and x64%29 83 | true 84 | 85 | 86 | False 87 | .NET Framework 3.5 SP1 88 | false 89 | 90 | 91 | 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /TobyLauncher/TobyLauncher/packages.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /TobyLauncher/TobyLauncher/toby.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankhale/toby/521d847a44fb331c342d0565841803243ee6fa44/TobyLauncher/TobyLauncher/toby.ico -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "toby", 3 | "private": true, 4 | "ignore": [ 5 | "**/.*", 6 | "node_modules", 7 | "bower_components", 8 | "public/components/", 9 | "test", 10 | "tests" 11 | ], 12 | "dependencies": { 13 | "jquery": "^3.3.1", 14 | "lodash": "^4.17.11", 15 | "socket.io-client": "^2.2.0", 16 | "keymaster": "^1.6.3" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /data/data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "group": "Trance", 4 | "entries": [ 5 | { 6 | "title": "A.M.R feat Ai Takekawa - Beyond The Moon (Exclusive Intro Edit)", 7 | "ytid": "LTUkrcYyXOo" 8 | }, 9 | { 10 | "title": "AMR feat Ai Takekawa - Beyond The Moon (Original Mix)", 11 | "ytid": "VXPor8AsY48" 12 | }, 13 | { 14 | "title": "Afrojack - Ten Feet Tall (Lyric Video) ft Wrabel", 15 | "ytid": "bltr_Dsk5EY" 16 | }, 17 | { 18 | "title": "Amir Hussain Time Lapse Extended Mix", 19 | "ytid": "pkSlIrODgcg" 20 | }, 21 | { 22 | "title": "Arcane Science feat Melissa Loretta - Still Feel (You Here) (Intro Mix)", 23 | "ytid": "v9zmvPSHj9I" 24 | }, 25 | { 26 | "title": "Armin van Buuren feat Fiora - Waiting For The Night (Official International Music Video)", 27 | "ytid": "dU26cGlmkRg" 28 | }, 29 | { 30 | "title": "Armin van Buuren feat Trevor Guthrie - This Is What It Feels Like (Official Music Video)", 31 | "ytid": "BR_DFMUzX4E" 32 | }, 33 | { 34 | "title": "Armin van Buuren feat. Angel Taylor - Make It Right (Ilan Bluestone & Maor Levi Remix)", 35 | "ytid": "x9559XApakc" 36 | }, 37 | { 38 | "title": "Armin van Buuren presents Gaia - J'ai Envie De Toi (Official Music Video)", 39 | "ytid": "0FgKQdc96k4" 40 | }, 41 | { 42 | "title": "Armin van Buuren vs Sophie Ellis-Bextor - Not Giving Up On Love (Dash Berlin 4AM Mix) Official Video", 43 | "ytid": "MQKt1RoUB9c" 44 | }, 45 | { 46 | "title": "BT feat Jes - Every other way (armin mix)", 47 | "ytid": "cTXdgzb4wkU" 48 | }, 49 | { 50 | "title": "Beat Service & Ana Criado - So Much Of Me Is You (Original Mix)", 51 | "ytid": "an_FDsq1Pkk" 52 | }, 53 | { 54 | "title": "Cosmic Gate & Emma Hewitt - Be Your Sound (Official Music Video)", 55 | "ytid": "FxMtO7HvXzA" 56 | }, 57 | { 58 | "title": "Craig Connelly & Christina Novelli - Black Hole [Official Music Video]", 59 | "ytid": "WPVv-Jjb0wQ" 60 | }, 61 | { 62 | "title": "Dash Berlin & Disfunktion feat Chris Arnott - People Of The Night", 63 | "ytid": "b4ZuXTiWX44" 64 | }, 65 | { 66 | "title": "Dash Berlin - Never Cry Again (Jorn van Deynhoven Radio Mix) (Official Music Video) [High Quality]", 67 | "ytid": "5gLIVqA9dFg" 68 | }, 69 | { 70 | "title": "Dash Berlin - Underneath The Sky (Sunsound Chillout Remix)", 71 | "ytid": "UEqMD-5urik" 72 | }, 73 | { 74 | "title": "Dash Berlin feat Jonathan Mendelsohn - Better Half Of Me (Acoustic)", 75 | "ytid": "De3Cjo23dOs" 76 | }, 77 | { 78 | "title": "Dash Berlin feat Jonathan Mendelsohn - Better Half Of Me (Acoustic) [Official Music Video]", 79 | "ytid": "De3Cjo23dOs" 80 | }, 81 | { 82 | "title": "Dash Berlin feat. Christon - Underneath The Sky (Official Music Video)", 83 | "ytid": "9oj1FTuscnY" 84 | }, 85 | { 86 | "title": "Dash Berlin ft Kate Walsh - When You Were Around (Official Music Video)", 87 | "ytid": "LlGtQyOv2WA" 88 | }, 89 | { 90 | "title": "Dash Berlin ft Sarah Howells - Go It Alone (Official Music Video)", 91 | "ytid": "IMepLfEJu6w" 92 | }, 93 | { 94 | "title": "Dreamy - Fusion (New World Remix) [Diverted Music Promo]", 95 | "ytid": "D_8y-XX9wM8" 96 | }, 97 | { 98 | "title": "Estiva & Cardinal feat Arielle Maren - Wait Forever (Daniel Kandi's Bangin Remix)", 99 | "ytid": "P0fp7fDxcVA" 100 | }, 101 | { 102 | "title": "Ferry Tayle & Betsie Larkin - The Key", 103 | "ytid": "wYo4Y_-GzU0" 104 | }, 105 | { 106 | "title": "Gareth Emery & Alastor feat. London Thor - Hands (Chris Metcalfe Remix)", 107 | "ytid": "CAlPf4IcH3A" 108 | }, 109 | { 110 | "title": "Gareth Emery - Long Way Home [Official Video]", 111 | "ytid": "0bj4i-sW44s" 112 | }, 113 | { 114 | "title": "Gareth Emery feat Bo Bruce - U (Official Video)", 115 | "ytid": "jF7qGaKKD4M" 116 | }, 117 | { 118 | "title": "Gareth Emery feat Christina Novelli - Concrete Angel [Official Music Video]", 119 | "ytid": "0dFz10R529g" 120 | }, 121 | { 122 | "title": "Gareth Emery feat Christina Novelli - Dynamite", 123 | "ytid": "N2rj3os3IYg" 124 | }, 125 | { 126 | "title": "Gareth Emery feat Roxanne Emery - Soldier", 127 | "ytid": "7b1WeyQ5cHw" 128 | }, 129 | { 130 | "title": "Gareth Emery feat Roxanne Emery - Too Dark Tonight", 131 | "ytid": "WZFgVGU7pFk" 132 | }, 133 | { 134 | "title": "Gareth Emery feat. Wayward Daughter - Reckless", 135 | "ytid": "tc7U9q1C4iA" 136 | }, 137 | { 138 | "title": "Giuseppe Ottaviani feat Amba Shepherd - Lost For Words (Official Music Video)", 139 | "ytid": "94CwUWWEgxw" 140 | }, 141 | { 142 | "title": "Ikerya Project feat Muhib Khan - It's Over Now (Original Mix) [As Played on Uplifting Only 115]", 143 | "ytid": "flXZE9izGP0" 144 | }, 145 | { 146 | "title": "Ikerya Project feat. Muhib Khan - Its Over Now (Original Mix) [Digital Euphoria Recordings]", 147 | "ytid": "QIklXSXdwP0" 148 | }, 149 | { 150 | "title": "Jasper Forks - River Flows In You (Out of Blackout Vocal Edit) [HD]", 151 | "ytid": "5UwnhliP5N8" 152 | }, 153 | { 154 | "title": "John O'Callaghan feat. Sarah Howells - Find Yourself (Standerwick Remix)", 155 | "ytid": "KlWTGeTf5c8" 156 | }, 157 | { 158 | "title": "Just Because", 159 | "ytid": "9tox6_Lmd3k" 160 | }, 161 | { 162 | "title": "Knife Party - Fire Hive (Krewella Remix) [Dubstep]", 163 | "ytid": "iuTOmyGXW6o" 164 | }, 165 | { 166 | "title": "Kyau & Albert - Another Time [Official Video] + Lyrics", 167 | "ytid": "zlBo93AqZmg" 168 | }, 169 | { 170 | "title": "Lost Frequencies - Are You With Me (Dash Berlin Remix) (Lyrics Music Video)", 171 | "ytid": "KYkRKvHYTls" 172 | }, 173 | { 174 | "title": "Luke Bond feat Roxanne Emery - On Fire [Official Music Video]", 175 | "ytid": "pNM7bjb9JYw" 176 | }, 177 | { 178 | "title": "MaRLo feat. Chloe - You And Me", 179 | "ytid": "6FTt18oTAEg" 180 | }, 181 | { 182 | "title": "Mart Sine ft. Christina Novelli - Carry You", 183 | "ytid": "rwRlHzdyyUQ" 184 | }, 185 | { 186 | "title": "Mike Saint-Jules & Amy Kirkpatrick - Galaxy (A.R.D.I. Remix)", 187 | "ytid": "ctrZdbExVrk" 188 | }, 189 | { 190 | "title": "Moonbeam feat Matvey Emerson - Wanderer (Original Mix)", 191 | "ytid": "hT8KPRbzP8g" 192 | }, 193 | { 194 | "title": "Morgan Page Sultan + Ned Shepard & BT - In the Air feat Angela McCluskey", 195 | "ytid": "WyK_82zrfTY" 196 | }, 197 | { 198 | "title": "Myon & Shane54 feat Aruna - Helpless", 199 | "ytid": "mgmPydUzhgg" 200 | }, 201 | { 202 | "title": "New World - Ushio (Emotional Intro Mix) [As Played on Uplifting Only 090]", 203 | "ytid": "gfAzQDCrUnQ" 204 | }, 205 | { 206 | "title": "Omar Sherif feat. Crystal Blakk - Hear You Calling (Extended Mix)", 207 | "ytid": "ehxlW4lTios" 208 | }, 209 | { 210 | "title": "Patrick Cassidy feat. Sibal - Mise ire (Bryan Kearney's UpRising Remix)", 211 | "ytid": "WhpBGfutDiM" 212 | }, 213 | { 214 | "title": "Paul van Dyk feat Adam Young - Eternity (Official Music Video)", 215 | "ytid": "FzT9YppKeeQ" 216 | }, 217 | { 218 | "title": "Phoebe Ryan - Mine (Illenium Remix)", 219 | "ytid": "OHid2FWShAw" 220 | }, 221 | { 222 | "title": "R3hab & NERVO & Ummet Ozcan - Revolution (Vocal Mix)", 223 | "ytid": "92P57Uk1VbM" 224 | }, 225 | { 226 | "title": "Rigby - Earth Meets Water (Official Video)", 227 | "ytid": "8d3fWzPzP4A" 228 | }, 229 | { 230 | "title": "Rigby - Earth Meets Water (Wildstylez Remix)", 231 | "ytid": "TyKvwXUy1aU" 232 | }, 233 | { 234 | "title": "Rigby - Lighter (Official Video)", 235 | "ytid": "dxa47ePobPs" 236 | }, 237 | { 238 | "title": "Roman Messer feat Christina Novelli - Frozen (Official Music Video)", 239 | "ytid": "LNJZSyl7RtE" 240 | }, 241 | { 242 | "title": "Skrux ft Delacey - My Love Is A Weapon", 243 | "ytid": "oqbmz9ZncEs" 244 | }, 245 | { 246 | "title": "Somna & Jennifer Rene - Awakening (Assaf Remix)", 247 | "ytid": "G9FRT9hQkaU" 248 | }, 249 | { 250 | "title": "Somna & Jennifer Rene - Awakening (Original Mix)", 251 | "ytid": "ldgV7H50ED8" 252 | }, 253 | { 254 | "title": "Somna & Jennifer Rene - Because You're Here (Sunset Remixi)", 255 | "ytid": "8hERMvja-fA" 256 | }, 257 | { 258 | "title": "Somna - Eon [A State Of Trance 761]", 259 | "ytid": "zglu7nahIYk" 260 | }, 261 | { 262 | "title": "SoundGate - The Last Time (Original Mix) Reburn Records [Promo Video]", 263 | "ytid": "TRO6xjybbnM" 264 | }, 265 | { 266 | "title": "Sub Focus - Tidal Wave ft Alpines", 267 | "ytid": "B8vlk1UR99k" 268 | }, 269 | { 270 | "title": "Sunbrothers - Everything (Original Mix)", 271 | "ytid": "wrMBe5cyNKA" 272 | }, 273 | { 274 | "title": "Sunlight Project feat Danny Claire - Stay (tranzLift Remix) Promo", 275 | "ytid": "yWQvWTM7Hqg" 276 | }, 277 | { 278 | "title": "Syntouch - Into The Sky (Extended Orchestral Mix) [As Played on Uplifting Only 109]", 279 | "ytid": "OuAkh9FqXs0" 280 | }, 281 | { 282 | "title": "Syntouch - Lost Coast (Original Mix) *Promo* [Trancer Recordings]", 283 | "ytid": "jj8EkplRBqk" 284 | }, 285 | { 286 | "title": "Temple One & Sarah Lynn - Show Me The Stars (Original Mix)", 287 | "ytid": "dTE9zXCmF7M" 288 | }, 289 | { 290 | "title": "Tom Swoon Lush & Simon - Ahead Of Us (Lyric Video)", 291 | "ytid": "Btz1lteUtuM" 292 | }, 293 | { 294 | "title": "Yellow Claw - Love & War (feat. Yade Lauren)", 295 | "ytid": "IqA3bKlP7kI" 296 | }, 297 | { 298 | "title": "Yuri Kane - Right back", 299 | "ytid": "G9AG0wkLIp0" 300 | }, 301 | { 302 | "title": "illitheas - Epica (Intro Mix) [Abora Skies] Promo [Uplifting Only #119] Video Edit", 303 | "ytid": "GxfwZMdSt4w" 304 | } 305 | ] 306 | }, 307 | { 308 | "group": "Trance Sets", 309 | "entries": [ 310 | { 311 | "title": "BEST OF VOCAL TRANCE | MIX#4", 312 | "ytid": "VZT9HGsv01A" 313 | }, 314 | { 315 | "title": "Dash Berlin - Live @ Ultra Music Festival Miami Mainstage 2015 (Full Set)", 316 | "ytid": "tS1NCigEB_I" 317 | }, 318 | { 319 | "title": "Dash Berlin - The Official Video Hit Mix", 320 | "ytid": "U6l9NdAJwRk" 321 | }, 322 | { 323 | "title": "Incredible Emotional Vocal Trance Mix l April 2015 (Vol 26)", 324 | "ytid": "3l2FHxVd71o" 325 | }, 326 | { 327 | "title": "Seven Lions Mix (Hour Long Melodic Dubstep / Trancestep Mix)", 328 | "ytid": "7KfKft2lcog" 329 | }, 330 | { 331 | "title": "The Best Of Paul Van Dyk (Essential Mix)", 332 | "ytid": "wg4TI4nA5IM" 333 | }, 334 | { 335 | "title": "This Was The Summer Of Hardstyle 2013", 336 | "ytid": "kQko_qnKoW4" 337 | }, 338 | { 339 | "title": "Trance mix 2014 - Armin Van BuurenW&WPaul Oakenfold", 340 | "ytid": "p68MTskBUnM" 341 | } 342 | ] 343 | }, 344 | { 345 | "group": "Christian", 346 | "entries": [ 347 | { 348 | "title": "7eventh Time Down 'Just Say Jesus'", 349 | "ytid": "T8CLgiYZyZE" 350 | }, 351 | { 352 | "title": "Among The Thirsty - Completely", 353 | "ytid": "sFC-d9PTusw" 354 | }, 355 | { 356 | "title": "Audrey Assad - Breaking You (Live)", 357 | "ytid": "RYuGuxr7MB0" 358 | }, 359 | { 360 | "title": "Audrey Assad - Death Be Not Proud", 361 | "ytid": "sikF_phQHZw" 362 | }, 363 | { 364 | "title": "Audrey Assad - I Shall Not Want", 365 | "ytid": "e5xEYgGr6ms" 366 | }, 367 | { 368 | "title": "Big Daddy Weave - Redeemed (Official Music Video)", 369 | "ytid": "VzGAYNKDyIU" 370 | }, 371 | { 372 | "title": "Chris Tomlin - Awake My Soul (with Lecrae) [Lyrics]", 373 | "ytid": "fWpvknKuYrg" 374 | }, 375 | { 376 | "title": "Chris Tomlin - Good Good Father (Live at the Grand Ole Opry)", 377 | "ytid": "eaqaER7dasY" 378 | }, 379 | { 380 | "title": "Chris Tomlin - Good Good Father ft. Pat Barrett", 381 | "ytid": "qlsQrycKKsY" 382 | }, 383 | { 384 | "title": "Chris Tomlin - How Great Is Our God (Lyrics And Chords)", 385 | "ytid": "KBD18rsVJHk" 386 | }, 387 | { 388 | "title": "Chris Tomlin - How Great is Our God", 389 | "ytid": "-20GqU9Q4UE" 390 | }, 391 | { 392 | "title": "Chris Tomlin - I Lift My Hands", 393 | "ytid": "c24En0r-lXg" 394 | }, 395 | { 396 | "title": "Chris Tomlin - I Will Rise (Live)", 397 | "ytid": "CKRF8UihM5s" 398 | }, 399 | { 400 | "title": "Chris Tomlin - Jesus Messiah (Lyrics And Chords)", 401 | "ytid": "tdxSC1tHJn0" 402 | }, 403 | { 404 | "title": "Chris Tomlin - Resurrection Power (Audio)", 405 | "ytid": "P0Gv89wzN_c" 406 | }, 407 | { 408 | "title": "Chris Tomlin - Whom Shall I Fear [God of Angel Armies] [Lyrics]", 409 | "ytid": "qOkImV2cJDg" 410 | }, 411 | { 412 | "title": "Crowder - Come As You Are (Music Video)", 413 | "ytid": "r2zhf2mqEMI" 414 | }, 415 | { 416 | "title": "Crowder - Come As You Are | Live at the Grand Ole Opry", 417 | "ytid": "OuX9oTGBCw8" 418 | }, 419 | { 420 | "title": "Crowder - I Am (Lyric Video)", 421 | "ytid": "cH_LLGiE0f0" 422 | }, 423 | { 424 | "title": "Danny Gokey - Hope In Front of Me", 425 | "ytid": "O5GFiDdGGGM" 426 | }, 427 | { 428 | "title": "Dave Barnes - God Gave Me You", 429 | "ytid": "-zkrQMjlD3A" 430 | }, 431 | { 432 | "title": "Francesca Battistelli - He Knows My Name (Official Audio)", 433 | "ytid": "1NHQJWdXfFE" 434 | }, 435 | { 436 | "title": "Francesca Battistelli - He Knows My Name (Official Video)", 437 | "ytid": "jYpBgJHmGmw" 438 | }, 439 | { 440 | "title": "Francesca Battistelli - Write Your Story (Official Audio)", 441 | "ytid": "ecV1NHmELuA" 442 | }, 443 | { 444 | "title": "Kari Jobe - I Am Not Alone (Lyric Video/Live)", 445 | "ytid": "I2oel0_Xa54" 446 | }, 447 | { 448 | "title": "Lauren Daigle - How Can It Be (Lyric Video)", 449 | "ytid": "6UXn_OuJkvE" 450 | }, 451 | { 452 | "title": "Love & The Outcome The God I Know (Official Audio)", 453 | "ytid": "gFeZB7Uz8yg" 454 | }, 455 | { 456 | "title": "Love & The Outcome - The God I Know (Official Music Video)", 457 | "ytid": "Px_aCjR5ZFA" 458 | }, 459 | { 460 | "title": "Luminate - Banner of Love (Lyric Video)", 461 | "ytid": "z4xk0dcc6gY" 462 | }, 463 | { 464 | "title": "Matt Redman - You Never Let Go (Passion '06)", 465 | "ytid": "76ifTTuL4XI" 466 | }, 467 | { 468 | "title": "NEEDTOBREATHE - 'Multiplied' (Live Acoustic Video)", 469 | "ytid": "vWT4gxAFQBs" 470 | }, 471 | { 472 | "title": "NEEDTOBREATHE - 'Multiplied' (Official Video)", 473 | "ytid": "fGF-MGGLpB0" 474 | }, 475 | { 476 | "title": "Sanctus Real - Lay It Down (Lyric Video)", 477 | "ytid": "q3f9GrbOXSA" 478 | }, 479 | { 480 | "title": "Stars Go Dim - You Are Loved [Official Music Video]", 481 | "ytid": "thSOeSduSDQ" 482 | }, 483 | { 484 | "title": "Switchfoot -- Love Alone Is Worth The Fight [Official Video]", 485 | "ytid": "rk9Pj3ID0UE" 486 | }, 487 | { 488 | "title": "Tell Your Heart To Beat Again (Lyrics) By: Danny Gokey", 489 | "ytid": "F77v41jbOYs" 490 | }, 491 | { 492 | "title": "Third Day - Cry Out To Jesus", 493 | "ytid": "JmVxRl5bc4Y" 494 | }, 495 | { 496 | "title": "TobyMac - Speak Life", 497 | "ytid": "ZeBv9r92VQ0" 498 | }, 499 | { 500 | "title": "Zach Williams - Chain Breaker (Official Lyric Video)", 501 | "ytid": "JGYjKR69M6U" 502 | } 503 | ] 504 | }, 505 | { 506 | "group": "misc", 507 | "entries": [ 508 | { 509 | "title": "30 Seconds To Mars - Alibi", 510 | "ytid": "CUn4j0g1XtU" 511 | }, 512 | { 513 | "title": "30 Seconds To Mars - Closer To The Edge", 514 | "ytid": "mLqHDhF-O28" 515 | }, 516 | { 517 | "title": "AMR feat Ai Takekawa - Beyond The Moon (Orbion Uplifting Remix)", 518 | "ytid": "liF-EZ4CFOw" 519 | }, 520 | { 521 | "title": "Aaron Lewis - What Hurts The Most HD Live in Lake Tahoe 8/06/2011", 522 | "ytid": "Yk9ByJZ0vrQ" 523 | }, 524 | { 525 | "title": "Ace of Base - The Sign (Official)", 526 | "ytid": "iqu132vTl5Y" 527 | }, 528 | { 529 | "title": "Aeden - Tempest Sonata (Six Senses Remix) [TFB Records] [EXCLUSIVE PREMIERE]", 530 | "ytid": "Y4pg6v74C5s" 531 | }, 532 | { 533 | "title": "Afternova - Empathy (Original Mix) [As Played on Uplifting Only 103]", 534 | "ytid": "eAT9RPQeU6A" 535 | }, 536 | { 537 | "title": "Alex H feat Mona Moua - There's No Turning Back (Original Mix)", 538 | "ytid": "9vYULN-72Oo" 539 | }, 540 | { 541 | "title": "Amir Hussain - Time Lapse (Extended Mix)", 542 | "ytid": "A7kDgupVz6g" 543 | }, 544 | { 545 | "title": "Andy Blueman - Reflections (Original 2004 Mix)", 546 | "ytid": "fB8u-vUNZhQ" 547 | }, 548 | { 549 | "title": "Annie Lennox - Why (Official Music Video)", 550 | "ytid": "HG7I4oniOyA" 551 | }, 552 | { 553 | "title": "Armin van Buuren - Who's Afraid Of 138?! (Photographer Remix)", 554 | "ytid": "2XlTEocKIWs" 555 | }, 556 | { 557 | "title": "Arnej feat Sally Saifi - Free Of You (8 Wonders Mix)", 558 | "ytid": "yr-kJ5PgAN4" 559 | }, 560 | { 561 | "title": "Audioleap - Spark [A State Of Trance 761]", 562 | "ytid": "gjpEJVK0lsc" 563 | }, 564 | { 565 | "title": "Aurosonic & Frainbreeze with Sarah Russell - Tell Me Anything (Original)", 566 | "ytid": "vox8sNkFdAQ" 567 | }, 568 | { 569 | "title": "Aurosonic & Neev Kennedy - Now I See (Chill Out Mix)", 570 | "ytid": "uBwIvFQwHr4" 571 | }, 572 | { 573 | "title": "Betsie Larkin & Solarstone - Breathe You In (Solarstone Pure Mix)", 574 | "ytid": "7-LpBG809Po" 575 | }, 576 | { 577 | "title": "BoB - Paper Route [Official Video]", 578 | "ytid": "t99LQ3zo8DU" 579 | }, 580 | { 581 | "title": "Bob Cartel feat Ai Takekawa - Lie To Me (Akku Remix) [Just Music Records] Promo", 582 | "ytid": "ZmPlAHxalfw" 583 | }, 584 | { 585 | "title": "Boyce Avenue - On My Way", 586 | "ytid": "Ie9VxRF7ucM" 587 | }, 588 | { 589 | "title": "Boyce Avenue - Speed Limit (Acoustic)", 590 | "ytid": "i9PxU3ZftmI" 591 | }, 592 | { 593 | "title": "Break Every Chain by Jesus Culture Lyrics", 594 | "ytid": "EtyVdC7E6Wo" 595 | }, 596 | { 597 | "title": "Chopin - The 21 Nocturnes (reference recording Claudio Arrau)", 598 | "ytid": "uUdoxvigIl8" 599 | }, 600 | { 601 | "title": "Chris Schweizer - Scorpion", 602 | "ytid": "wZlDOf63l2g" 603 | }, 604 | { 605 | "title": "Cinderella - Long Cold Winter - 1988 (FULL ALBUM)", 606 | "ytid": "BldJ5ejRKqw" 607 | }, 608 | { 609 | "title": "Colleen D'Agostino ft deadmau5 - Stay (Drop The Poptart Edit)", 610 | "ytid": "YPkwDy9okws" 611 | }, 612 | { 613 | "title": "Come Alive (Dry Bones) featuring Lauren Daigle - Live from the Centric Worship Retreat", 614 | "ytid": "7XAeyFagceQ" 615 | }, 616 | { 617 | "title": "DEF LEPPARD - Long Long Way To Go (Official Music Video)", 618 | "ytid": "E2KTcr1Mir0" 619 | }, 620 | { 621 | "title": "Danger Danger - I Still Think About You", 622 | "ytid": "id1Po8ryJrU" 623 | }, 624 | { 625 | "title": "Dark Fusion ft Amy Kirkpatrick - I Just Close My Eyes (Sunset Remix) [TranceFamily] Promo Video", 626 | "ytid": "avuoLKAi718" 627 | }, 628 | { 629 | "title": "Dark Matters feat Jess Morgan - The Perfect Lie (Beat Service Remix)", 630 | "ytid": "dGd-QFuEUTA" 631 | }, 632 | { 633 | "title": "Dash Berlin & Syzz feat. Adam Jensen - Leave It All Behind", 634 | "ytid": "OKgmwQNSW1g" 635 | }, 636 | { 637 | "title": "Dash Berlin - #MusicIsLife - Album", 638 | "ytid": "DSOEUj8FAQ4" 639 | }, 640 | { 641 | "title": "Dash Berlin ft Sarah Howells - Go It Alone (Andrew Rayel Remix)(Official Music Video)", 642 | "ytid": "eWaANlb3q68" 643 | }, 644 | { 645 | "title": "Denis Kenzo & Jilliana Danise-Will Be Forever (Original) [Lyric video]", 646 | "ytid": "4mwbBTy607U" 647 | }, 648 | { 649 | "title": "Denis Kenzo & Sveta B - Let Me Go (Original Mix)", 650 | "ytid": "ZBBjH9-FqOA" 651 | }, 652 | { 653 | "title": "Denis Kenzo & Sveta B. - Sunshine Blue", 654 | "ytid": "YfWbSSoEZmg" 655 | }, 656 | { 657 | "title": "Denis Sender - Liquid Dreams (Ellez Ria Remix)", 658 | "ytid": "2Vjjx72CVSY" 659 | }, 660 | { 661 | "title": "Dennis Sheperd & Sarah Lynn - Dive (Official Music Video) A Tribute To Life/RNM", 662 | "ytid": "phmNqAiv7zQ" 663 | }, 664 | { 665 | "title": "Dennis Sheperd & Sarah Lynn - Dive (Original Mix)", 666 | "ytid": "1CaNyznSA_c" 667 | }, 668 | { 669 | "title": "Dreamy - Fusion (New World Remix) [Diverted Music]", 670 | "ytid": "7MWYPUWUWjQ" 671 | }, 672 | { 673 | "title": "Eric Stanley ft Kyle Landry - Inception 'Time' (Hans Zimmer Cover)", 674 | "ytid": "NUtvWyJ0F_o" 675 | }, 676 | { 677 | "title": "Eric Zimmer - Sanctuary (TrancEye Remix) [D.MAX Recordings]", 678 | "ytid": "rJjLz-3xeu0" 679 | }, 680 | { 681 | "title": "Farhad Mahdavi - Yearning [As Played on Uplifting Only 126]", 682 | "ytid": "GqTM7eKZITg" 683 | }, 684 | { 685 | "title": "Ferry Corsten feat Betsie Larkin - Not Coming Down (Dash Berlin 4AM Remix)", 686 | "ytid": "A-gljGbLt-A" 687 | }, 688 | { 689 | "title": "Ferry Corsten presents Gouryella - Anahera [Official Music Video]", 690 | "ytid": "7ZMZHbAKvGA" 691 | }, 692 | { 693 | "title": "Fun - Carry On [OFFICIAL VIDEO]", 694 | "ytid": "q7yCLn-O-Y0" 695 | }, 696 | { 697 | "title": "Fun Some Nights [OFFICIAL VIDEO]", 698 | "ytid": "qQkBeOisNM0" 699 | }, 700 | { 701 | "title": "Fun We Are Young ft Janelle Monae [OFFICIAL VIDEO]", 702 | "ytid": "Sv6dMFF_yts" 703 | }, 704 | { 705 | "title": "Gareth Emery & Alastor feat. London Thor - Hands (Chris Metcalfe Remix) [A State Of Trance 742]", 706 | "ytid": "kz7LEuBG2v4" 707 | }, 708 | { 709 | "title": "Gareth Emery Feat. Lydia McAllister - Reckless (Standerwick Remix)", 710 | "ytid": "_R9JpmSL6To" 711 | }, 712 | { 713 | "title": "Gareth Emery Feat. Wayward Daughter - Reckless (Standerwick Remix)", 714 | "ytid": "P58FOqUMSH4" 715 | }, 716 | { 717 | "title": "Gareth Emery feat. Gavrielle - Far From Home (Craig Connelly Extended Remix)", 718 | "ytid": "637k35PSAzk" 719 | }, 720 | { 721 | "title": "Gareth Emery feat. Gavrielle - Far From Home (Craig Connelly Remix) [A State Of Trance 761]", 722 | "ytid": "FCRY7VHukjE" 723 | }, 724 | { 725 | "title": "Gareth Emery feat. Wayward Daughter - Reckless", 726 | "ytid": "J78Zp5i5Loo" 727 | }, 728 | { 729 | "title": "Gareth Emery feat. Wayward Daughter - Reckless (Standerwick Remix) STANDERWICK Live ASOT 750 Toronto", 730 | "ytid": "Y7CGTzM5nsQ" 731 | }, 732 | { 733 | "title": "Gareth Emery feat. Wayward Daughter - Reckless (Standerwick Remix) [A State Of Trance 753]", 734 | "ytid": "-mOCl2XP4kE" 735 | }, 736 | { 737 | "title": "Hazem Beltagui & Allan V. - We Are (Original Mix)", 738 | "ytid": "XArJECXJ4Lw" 739 | }, 740 | { 741 | "title": "Howard Jones - No One Is To Blame", 742 | "ytid": "ENB2eX-U3a8" 743 | }, 744 | { 745 | "title": "Hozier - Take Me To Church", 746 | "ytid": "PVjiKRfKpPI" 747 | }, 748 | { 749 | "title": "Inception - Time - Piano Solo HD", 750 | "ytid": "JOtDS0vRwxg" 751 | }, 752 | { 753 | "title": "Jak Aggas & Victoria Shersick - Fly Away (Allen Watts Remix) [Amsterdam Trance] Video Edit", 754 | "ytid": "NUcJfPTMXkk" 755 | }, 756 | { 757 | "title": "Kelly Clarkson Performs - Piece by Piece", 758 | "ytid": "9FHYBQxURQo" 759 | }, 760 | { 761 | "title": "Lqd Hrmny - La Distance", 762 | "ytid": "DhqYIyvnAvA" 763 | }, 764 | { 765 | "title": "Ludovico Einaudi - Nuvole Bianche [HD]", 766 | "ytid": "kcihcYEOeic" 767 | }, 768 | { 769 | "title": "Maratone & Dreamy feat. Emma Chatt - Out From Under (Original Mix) [REDUX] -Promo- Video Edit", 770 | "ytid": "FnERt5fGoOg" 771 | }, 772 | { 773 | "title": "Mark Eteson feat Audrey Gallagher - Breathe On My Own [Official Music Video]", 774 | "ytid": "jwO51I9zlaE" 775 | }, 776 | { 777 | "title": "Matt Maher - Lord I Need You (feat Audrey Assad) - Acoustic", 778 | "ytid": "iaVPupbNFAo" 779 | }, 780 | { 781 | "title": "Metallica - Enter Sandman Live Moscow 1991 HD", 782 | "ytid": "_W7wqQwa-TU" 783 | }, 784 | { 785 | "title": "Metallica Fade to Black in real HD !!!! awesome !!!!", 786 | "ytid": "0FMfsT11pdA" 787 | }, 788 | { 789 | "title": "Oceanlab - Secret (Andrew Bayer Remix)", 790 | "ytid": "K3crNooxyAM" 791 | }, 792 | { 793 | "title": "One Republic - Apologize", 794 | "ytid": "fm0T7_SGee4" 795 | }, 796 | { 797 | "title": "Ong Namo by Snatam Kaur", 798 | "ytid": "c1XCS0g6J4A" 799 | }, 800 | { 801 | "title": "PJ Simas - Show Goes On Remix", 802 | "ytid": "vAhjWd4_LZ0" 803 | }, 804 | { 805 | "title": "Rachel Platten - Fight Song (Official Video)", 806 | "ytid": "xo1VInw-SKc" 807 | }, 808 | { 809 | "title": "Robyn - Call Your Girlfriend - cover by Kait Weston Ft Sean Scanlon", 810 | "ytid": "VAlgkvKTOZ0" 811 | }, 812 | { 813 | "title": "Simon O'Shine & Sergey Nevone - Last Goodbye (Original Mix)", 814 | "ytid": "QLqbwLPE-LY" 815 | }, 816 | { 817 | "title": "Solis & Sean Truby feat. Anthya - Timeless (Ultimate Remix) [Infrasonic Recordings]", 818 | "ytid": "2v0zijZjC0U" 819 | }, 820 | { 821 | "title": "Taylor Swift - Style", 822 | "ytid": "-CmadmM5cOk" 823 | }, 824 | { 825 | "title": "Tenishia - Where Do We Begin (Andrew Rayel Remix)", 826 | "ytid": "CgU8VIna5aM" 827 | }, 828 | { 829 | "title": "The Chainsmokers - Let You Go ft Great Good Fine Ok", 830 | "ytid": "iiNXf0n_hrA" 831 | }, 832 | { 833 | "title": "The Fighter Remix - Kait Weston (feat Saint Maurice and Eppic)", 834 | "ytid": "ijRVrGSaTms" 835 | }, 836 | { 837 | "title": "The Weeknd - Earned It (Fifty Shades Of Grey) (Lyric Video)", 838 | "ytid": "xe_iCkFsQKE" 839 | }, 840 | { 841 | "title": "Thirty Seconds To Mars - Kings and Queens", 842 | "ytid": "onMdBNeGiK4" 843 | }, 844 | { 845 | "title": "Thirty Seconds To Mars - This Is War", 846 | "ytid": "Zcps2fJKuAI" 847 | }, 848 | { 849 | "title": "Tom Swoon, Lush & Simon - Ahead of Us", 850 | "ytid": "dH8hqgoRVFY" 851 | }, 852 | { 853 | "title": "UDM - New Sunrise (Radio Edit) [As Played on Uplifting Only 124]", 854 | "ytid": "kC44uGao2es" 855 | }, 856 | { 857 | "title": "Usher - Yeah! ft Lil Jon Ludacris", 858 | "ytid": "GxBSyx85Kp8" 859 | }, 860 | { 861 | "title": "Varun Rajput - State of Grace (Liquid Tension Experiment Cover)", 862 | "ytid": "2bVNP9vd4FU" 863 | }, 864 | { 865 | "title": "Velvetine - The Great Divide (Seven Lions Remix)", 866 | "ytid": "LoKJd2nTpjs" 867 | }, 868 | { 869 | "title": "Wiz Khalifa - See You Again ft. Charlie Puth [Official Video] Furious 7 Soundtrack", 870 | "ytid": "RgKAFK5djSk" 871 | }, 872 | { 873 | "title": "Yiruma - River Flows in You", 874 | "ytid": "7maJOI3QMu0" 875 | }, 876 | { 877 | "title": "You Raise Me Up Violin Cover - Josh Groban - Daniel Jang", 878 | "ytid": "Z-zvV5Bsshc" 879 | }, 880 | { 881 | "title": "illitheas & Johannes Fischer - Tears of Hope (Chillout Mix) [As Played on Uplifting Only 154]", 882 | "ytid": "PPXT3eMkk3w" 883 | }, 884 | { 885 | "title": "illitheas & Johannes Fischer - Tears of Hope (Original Mix) [As Played on Uplifting Only 153]", 886 | "ytid": "aN1oTHHNafc" 887 | }, 888 | { 889 | "title": "illitheas - Endless (Final Version) [As Played on Uplifting Only 113]", 890 | "ytid": "kqnOnilA6ac" 891 | }, 892 | { 893 | "title": "illitheas - Halion (Original Mix) [Digital Euphoria] Promo/Uplifting Only #168 ASOT 761", 894 | "ytid": "i8EUsjx-Yao" 895 | } 896 | ] 897 | }, 898 | { 899 | "group": "Recently Played", 900 | "entries": [ 901 | { 902 | "title": "A.M.R feat Ai Takekawa - Beyond The Moon (Exclusive Intro Edit)", 903 | "ytid": "LTUkrcYyXOo" 904 | }, 905 | { 906 | "title": "ATB with Dash Berlin - Apollo Road (Official Video HD)", 907 | "ytid": "rBmfDJvamQY" 908 | }, 909 | { 910 | "title": "Ferry Corsten feat. Betsie Larkin - Not Coming Down (Dash Berlin 4AM Remix)", 911 | "ytid": "A-gljGbLt-A" 912 | }, 913 | { 914 | "title": "Ferry Tayle & Betsie Larkin - The Key", 915 | "ytid": "wYo4Y_-GzU0" 916 | }, 917 | { 918 | "title": "MaRLo feat. Chloe - You And Me", 919 | "ytid": "6FTt18oTAEg" 920 | }, 921 | { 922 | "title": "Mart Sine ft. Christina Novelli - Carry You", 923 | "ytid": "rwRlHzdyyUQ" 924 | }, 925 | { 926 | "title": "Roger Shah & JES - Star-Crossed", 927 | "ytid": "gD_L-6zmjeI" 928 | } 929 | ] 930 | } 931 | ] -------------------------------------------------------------------------------- /definitions/README.md: -------------------------------------------------------------------------------- 1 | ### NOTE 2 | 3 | The `youtube-search.d.ts` listed here is only for reference purposes as it has 4 | now been included into the `youtube-search` module. -------------------------------------------------------------------------------- /definitions/youtube-search.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare namespace search { 3 | export interface YouTubeSearchOptions { 4 | fields?: string; 5 | channelId?: string; 6 | channelType?: string; 7 | eventType?: string; 8 | forContentOwner?: boolean; 9 | forDeveloper?: boolean; 10 | forMine?: boolean; 11 | location?: string; 12 | locationRadius?: string; 13 | maxResults?: number; 14 | onBehalfOfContentOwner?: string; 15 | order?: string; 16 | part?: string; 17 | pageToken?: string; 18 | publishedAfter?: string; 19 | publishedBefore?: string; 20 | regionCode?: string; 21 | relatedToVideoId?: string; 22 | relevanceLanguage?: string; 23 | safeSearch?: string; 24 | topicId?: string; 25 | type?: string; 26 | videoCaption?: string; 27 | videoCategoryId?: string; 28 | videoDefinition?: string; 29 | videoDimension?: string; 30 | videoDuration?: string; 31 | videoEmbeddable?: string; 32 | videoLicense?: string; 33 | videoSyndicated?: string; 34 | videoType?: string; 35 | key?: string; 36 | } 37 | 38 | export interface YouTubeSearchResults { 39 | id: string; 40 | link: string; 41 | kind: string; 42 | publishedAt: string; 43 | channelId: string; 44 | title: string; 45 | description: string; 46 | thumbnails: string; 47 | } 48 | 49 | export interface YouTubeSearchPageResults { 50 | totalResults: number; 51 | resultsPerPage: number; 52 | nextPageToken: string; 53 | prevPageToken: string; 54 | } 55 | } 56 | 57 | declare function search( 58 | term: string, 59 | opts: search.YouTubeSearchOptions, 60 | cb: (err: Error, result?: search.YouTubeSearchResults[], pageInfo?: search.YouTubeSearchPageResults) => void 61 | ): (err: Error, result?: search.YouTubeSearchResults[], pageInfo?: search.YouTubeSearchPageResults) => void; 62 | 63 | export = search; 64 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | 13 | 21 | 22 | 23 | 24 | 25 |
26 | 27 |
28 |
29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "toby", 3 | "version": "1.0.10", 4 | "description": "A simple YouTube player", 5 | "title": "Toby - A simple YouTube player", 6 | "homepage": "https://github.com/frankhale/toby", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/frankhale/toby.git" 10 | }, 11 | "private": true, 12 | "author": "Frank Hale ", 13 | "license": "GPL-3.0", 14 | "main": "./build/index.html", 15 | "node-remote": "http://localhost:62374", 16 | "user-agent": "node-webkit-%nwver", 17 | "window": { 18 | "icon": "./public/images/toby.png", 19 | "toolbar": false, 20 | "width": 640, 21 | "height": 369, 22 | "min_width": 640, 23 | "min_height": 369, 24 | "show": false 25 | }, 26 | "devDependencies": { 27 | "@types/body-parser": "^1.17.1", 28 | "@types/cookie-parser": "^1.4.2", 29 | "@types/debug": "^4.1.5", 30 | "@types/express": "^4.17.2", 31 | "@types/jquery": "^3.3.31", 32 | "@types/keymaster": "^1.6.28", 33 | "@types/lodash": "^4.14.148", 34 | "@types/morgan": "^1.7.37", 35 | "@types/node": "^12.12.8", 36 | "@types/nw.js": "^0.13.8", 37 | "@types/react": "^16.9.11", 38 | "@types/react-dom": "^16.9.4", 39 | "@types/request": "^2.48.3", 40 | "@types/serve-favicon": "^2.2.31", 41 | "@types/socket.io": "^2.1.4", 42 | "@types/socket.io-client": "^1.4.32", 43 | "@types/sqlite3": "^3.1.5", 44 | "@types/youtube": "^0.0.38", 45 | "grunt": "^1.0.4", 46 | "grunt-contrib-copy": "^1.0.0", 47 | "grunt-ts": "^6.0.0-beta.22", 48 | "grunt-tslint": "^5.0.2", 49 | "source-map-loader": "^0.2.4", 50 | "ts-loader": "^6.2.1", 51 | "tslint": "^5.20.1", 52 | "typescript": "^3.7.2", 53 | "uglifyjs-webpack-plugin": "^2.2.0", 54 | "webpack": "^4.41.2" 55 | }, 56 | "dependencies": { 57 | "body-parser": "^1.19.0", 58 | "cookie-parser": "^1.4.4", 59 | "debug": "^4.1.1", 60 | "express": "^4.17.1", 61 | "hbs": "^4.0.6", 62 | "jquery": "^3.5.0", 63 | "lodash": "^4.17.15", 64 | "moment": "^2.24.0", 65 | "morgan": "^1.9.1", 66 | "node": "^12.13.0", 67 | "react": "^16.12.0", 68 | "react-dom": "^16.12.0", 69 | "request": "^2.88.0", 70 | "serve-favicon": "^2.5.0", 71 | "socket.io": "^2.3.0", 72 | "split": "^1.0.1", 73 | "sqlite3": "^4.1.0", 74 | "title-case": "^2.1.1", 75 | "youtube-search": "^1.1.4" 76 | }, 77 | "webview": { 78 | "partitions": [ 79 | { 80 | "name": "trusted", 81 | "accessible_resources": [ 82 | "" 83 | ] 84 | } 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /public/images/toby.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankhale/toby/521d847a44fb331c342d0565841803243ee6fa44/public/images/toby.ico -------------------------------------------------------------------------------- /public/images/toby.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankhale/toby/521d847a44fb331c342d0565841803243ee6fa44/public/images/toby.png -------------------------------------------------------------------------------- /public/stylesheets/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 0; 3 | margin: 0; 4 | background-color: #000; 5 | color: #fff; 6 | font-size: 18pt; 7 | overflow: hidden; 8 | } 9 | 10 | #ui { 11 | position: absolute; 12 | width: 100%; 13 | height: 100%; 14 | overflow: hidden; 15 | } 16 | 17 | #ui:hover { 18 | overflow-y: scroll; 19 | } 20 | 21 | #player { 22 | position: absolute; 23 | width: 100%; 24 | height: 100%; 25 | display: none; 26 | } 27 | 28 | * { 29 | font-family: "Share Tech Mono", monospace; 30 | } 31 | 32 | pre { 33 | outline: none !important; 34 | border-left: 4px solid black; 35 | background-color: #eee; 36 | white-space: pre-wrap; 37 | margin-left: 40px !important; 38 | margin-right: 40px !important; 39 | } 40 | 41 | blockquote { 42 | border-left: 4px solid black; 43 | background-color: #eee; 44 | padding: 10px; 45 | } 46 | 47 | /* Command Input */ 48 | 49 | .command-container { 50 | padding-left: 10px; 51 | font-size: 18pt; 52 | } 53 | 54 | .command-input { 55 | caret-color: white; 56 | border: none; 57 | outline: none; 58 | padding-left: 2px; 59 | padding-top: 5px; 60 | margin-bottom: 20px; 61 | font-size: 18pt; 62 | background-color: #000; 63 | color: #fff; 64 | } 65 | 66 | /* Content Panel */ 67 | 68 | .content-panel { 69 | padding-left: 10px; 70 | padding-bottom: 25px; 71 | font-size: 18px; 72 | } 73 | 74 | /* misc */ 75 | 76 | .thumbnailIMGWidth { 77 | width: 50px; 78 | } 79 | 80 | .buttonContainerWidth { 81 | width: 1px; 82 | white-space: nowrap; 83 | } 84 | 85 | .textAlignMiddle { 86 | vertical-align: middle; 87 | } 88 | 89 | table { 90 | border-collapse: collapse; 91 | width: 100%; 92 | } 93 | 94 | tr:hover td { 95 | background-color: #003e80; 96 | color: #fff; 97 | text-shadow: 0 0 6px #001130; 98 | font-weight: bold; 99 | cursor: pointer; 100 | } 101 | 102 | tr:hover td.border-left { 103 | border-radius: 8px 0 0 8px; 104 | } 105 | 106 | tr:hover td.border-right { 107 | border-radius: 0 8px 8px 0; 108 | } 109 | 110 | .alignDiv { 111 | display: inline-block; 112 | vertical-align: middle; 113 | } 114 | 115 | .videoTitle { 116 | cursor: default; 117 | } 118 | 119 | .videoThumbnail { 120 | margin-top: 4px; 121 | padding: 6px; 122 | width: 90px; 123 | } 124 | 125 | .videoThumbnailSlim { 126 | margin-left: 5px; 127 | margin-top: 4px; 128 | padding: 6px; 129 | width: 80px; 130 | border: 4px solid black; 131 | } 132 | 133 | .videoThumbnailSlim:hover { 134 | border: 4px solid #003e80; 135 | } 136 | 137 | .videoAddedNotification { 138 | position: fixed; 139 | border: 2px solid #001130; 140 | background-color: #003e80; 141 | color: #fff; 142 | padding: 10px; 143 | top: 5px; 144 | right: 5px; 145 | font-size: 11pt; 146 | z-index: 10000; 147 | } 148 | 149 | .manageButton { 150 | color: #fff; 151 | vertical-align: middle; 152 | text-decoration: none; 153 | margin-right: 2px; 154 | } 155 | 156 | .manageButton:hover { 157 | color: #999; 158 | } 159 | 160 | .grayscale { 161 | -webkit-filter: grayscale(1); 162 | } 163 | 164 | .saturate { 165 | -webkit-filter: saturate(2.5); 166 | } 167 | 168 | .sepia { 169 | -webkit-filter: sepia(1); 170 | } 171 | 172 | ::-webkit-scrollbar { 173 | height: 8px; 174 | width: 8px; 175 | background: #000; 176 | } 177 | 178 | ::-webkit-scrollbar-thumb { 179 | background: #999; 180 | -webkit-border-radius: 1ex; 181 | -webkit-box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.75); 182 | } 183 | 184 | ::-webkit-scrollbar-corner { 185 | background: #fff; 186 | } 187 | 188 | /* Select style borrowed from: http://codepen.io/AmrSubZero/pen/dxpri */ 189 | 190 | select { 191 | background-color: #357; 192 | background-repeat: no-repeat; 193 | background-position: right 10px top 10px; 194 | background-size: 11px 11px; 195 | padding: 8px; 196 | width: auto; 197 | font-family: arial, tahoma; 198 | font-size: 10px; 199 | font-weight: bold; 200 | color: #fff; 201 | text-align: center; 202 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); 203 | border-radius: 3px; 204 | /*-webkit-appearance: none;*/ 205 | border: 0; 206 | outline: 0; 207 | -webkit-transition: 0.3s ease all; 208 | transition: 0.3s ease all; 209 | } 210 | 211 | select:focus, 212 | select:active { 213 | border: 0; 214 | outline: 0; 215 | } 216 | 217 | .groupDropDown:hover { 218 | background-color: #123; 219 | } 220 | 221 | .groupDropDownDisabled { 222 | margin-right: 5px; 223 | background-color: #333; 224 | width: 110px; 225 | } 226 | 227 | .groupDropDown { 228 | margin-right: 5px; 229 | width: 110px; 230 | } 231 | 232 | #version { 233 | position: fixed; 234 | bottom: 25px; 235 | right: 25px; 236 | font-weight: bold; 237 | color: #444; 238 | opacity: 0.5; 239 | } -------------------------------------------------------------------------------- /public/stylesheets/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | background-color: #000; 4 | color: #fff; 5 | padding: 0; 6 | margin: 0; 7 | overflow: hidden; 8 | } 9 | 10 | * { 11 | font-family: "Share Tech Mono", monospace; 12 | } 13 | 14 | #content { 15 | position: absolute; 16 | width: 100%; 17 | height: 100%; 18 | overflow: auto; 19 | visibility: hidden; 20 | padding: 10px; 21 | } 22 | 23 | #loading { 24 | position: absolute; 25 | width: 100%; 26 | top: 45%; 27 | text-align: center; 28 | } 29 | 30 | #webview { 31 | position: absolute; 32 | top: 0; 33 | left: 0; 34 | width: 100%; 35 | height: 100%; 36 | z-index: 1000; 37 | display: inline-flex !important; 38 | } 39 | 40 | ::-webkit-scrollbar { 41 | height: 8px; 42 | width: 8px; 43 | background: #000; 44 | } 45 | ::-webkit-scrollbar-thumb { 46 | background: #999; 47 | -webkit-border-radius: 1ex; 48 | -webkit-box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.75); 49 | } 50 | ::-webkit-scrollbar-corner { 51 | background: #fff; 52 | } 53 | -------------------------------------------------------------------------------- /screenshots/toby-main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankhale/toby/521d847a44fb331c342d0565841803243ee6fa44/screenshots/toby-main.png -------------------------------------------------------------------------------- /screenshots/toby-manage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankhale/toby/521d847a44fb331c342d0565841803243ee6fa44/screenshots/toby-manage.png -------------------------------------------------------------------------------- /screenshots/toby-recently-played.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankhale/toby/521d847a44fb331c342d0565841803243ee6fa44/screenshots/toby-recently-played.png -------------------------------------------------------------------------------- /screenshots/toby-server-log.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankhale/toby/521d847a44fb331c342d0565841803243ee6fa44/screenshots/toby-server-log.png -------------------------------------------------------------------------------- /screenshots/toby-video-list-slim-grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankhale/toby/521d847a44fb331c342d0565841803243ee6fa44/screenshots/toby-video-list-slim-grid.png -------------------------------------------------------------------------------- /screenshots/toby-video-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankhale/toby/521d847a44fb331c342d0565841803243ee6fa44/screenshots/toby-video-list.png -------------------------------------------------------------------------------- /screenshots/toby-video-playback.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankhale/toby/521d847a44fb331c342d0565841803243ee6fa44/screenshots/toby-video-playback.png -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | // api.js - Express API for Toby 2 | // Author(s): Frank Hale 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU General Public License 15 | // along with this program. If not, see . 16 | 17 | import * as _ from "lodash"; 18 | import * as express from "express"; 19 | import * as fs from "fs"; 20 | import * as http from "http"; 21 | 22 | import * as youtubeSearch from "youtube-search"; 23 | 24 | import { IVideoGroup, IVideoEntry } from "./infrastructure"; 25 | import { SearchCache } from "./searchCache"; 26 | 27 | import AppConfig from "./config"; 28 | import DB from "./db"; 29 | import DefaultData from "./data"; 30 | 31 | interface APIRoute { 32 | path: string; 33 | route: (req: express.Request, res: express.Response) => void; 34 | } 35 | 36 | export default class API { 37 | private db: DB; 38 | private server: http.Server; 39 | private routes: APIRoute[]; 40 | private cache: SearchCache; 41 | public router: express.Router; 42 | 43 | constructor(db: DB, server: http.Server) { 44 | this.db = db; 45 | this.router = express.Router(); 46 | this.server = server; 47 | this.cache = new SearchCache(); 48 | 49 | this.routes = [ 50 | { path: "GET /videos", route: this.getVideos }, 51 | { path: "GET /videos/groups", route: this.getVideosGroups }, 52 | { path: "GET /videos/archive", route: this.getVideosArchive }, 53 | { path: "POST /app/close", route: this.postAppClose }, 54 | { 55 | path: "POST /videos/youtube/search", 56 | route: this.postVideosYouTubeSearch 57 | }, 58 | { path: "POST /videos/search", route: this.postVideosSearch }, 59 | { path: "POST /videos/add", route: this.postVideosAdd }, 60 | { path: "POST /videos/delete", route: this.postVideosDelete }, 61 | { path: "POST /videos/update", route: this.postVideosUpdate }, 62 | { 63 | path: "POST /videos/recently-played/add", 64 | route: this.postVideosRecentlyPlayedAdd 65 | }, 66 | { 67 | path: "POST /videos/recently-played/search", 68 | route: this.postVideosRecentlyPlayedSearch 69 | }, 70 | { 71 | path: "POST /videos/recently-played/last30", 72 | route: this.postVideosRecentlyPlayedLas30 73 | } 74 | ]; 75 | 76 | this.db.importIntoDB(DefaultData.getData()); 77 | 78 | this.initializeRoutes(); 79 | } 80 | private initializeRoutes(): void { 81 | _.forEach(this.routes, r => { 82 | let routePath = r.path.split(" "); 83 | this.router[routePath[0].toLowerCase()](routePath[1], r.route.bind(this)); 84 | }); 85 | } 86 | private createDataFileString(data: IVideoGroup[]): string { 87 | return JSON.stringify(data, null, 2); 88 | } 89 | private writeDataFile(dataFilePath: string, dataString: string): void { 90 | try { 91 | fs.writeFileSync(dataFilePath, dataString, "utf8"); 92 | } catch (e) { 93 | console.log(`Error writing data file: ${e}`); 94 | } 95 | } 96 | private getVideos(_req: express.Request, res: express.Response): void { 97 | this.db.getAllVideosFromDB(data => { 98 | res.json(data); 99 | }); 100 | } 101 | private getVideosGroups(_req: express.Request, res: express.Response): void { 102 | this.db.getAllGroupsFromDB(data => { 103 | data = _.map(data, d => { 104 | return d.group; 105 | }); 106 | res.json(data); 107 | }); 108 | } 109 | private getVideosArchive(_req: express.Request, res: express.Response): void { 110 | this.db.getAllGroupsFromDB(groups => { 111 | this.db.getAllVideosOrderedByGroupDB(data => { 112 | let results: IVideoGroup[] = []; 113 | 114 | _.forEach(groups, g => { 115 | let entries = _.sortBy(_.filter(data, { group: g.group }), ["title"]); 116 | 117 | entries = _.map(entries, e => { 118 | return { 119 | title: e.title.replace(/[^\x00-\x7F]/g, ""), 120 | ytid: e.ytid 121 | }; 122 | }); 123 | 124 | results.push({ 125 | group: g.group, 126 | entries: entries 127 | }); 128 | }); 129 | 130 | let dataFileString = this.createDataFileString(results); 131 | this.writeDataFile(AppConfig.dataFilePath, dataFileString); 132 | 133 | res.send(dataFileString); 134 | }); 135 | }); 136 | } 137 | private postAppClose(_req: Express.Request, _res: Express.Response): void { 138 | this.db.close(); 139 | this.server.close(); 140 | process.exit(0); 141 | } 142 | private postVideosYouTubeSearch( 143 | req: express.Request, 144 | res: express.Response 145 | ): void { 146 | let searchTerm = req.body.searchTerm; 147 | 148 | if (searchTerm.indexOf("/yt") > -1) { 149 | searchTerm = searchTerm.replace("/yt", ""); 150 | } 151 | 152 | youtubeSearch(searchTerm, AppConfig.youtubeSearchOpts, (err, results) => { 153 | if (err) return console.log(err); 154 | 155 | const ytids = _.map(results, r => { 156 | return r.id; 157 | }); 158 | 159 | this.db.getAllVideosWhereYTIDInList(ytids, ytids_found => { 160 | let finalResults: IVideoEntry[] = []; 161 | 162 | _.forEach(results, r => { 163 | let found = _.find(ytids_found, f => { 164 | return f.ytid === r.id; 165 | }); 166 | 167 | finalResults.push({ 168 | title: r.title, 169 | ytid: r.id, 170 | group: found ? found.group : "", 171 | isArchived: found ? true : false 172 | }); 173 | }); 174 | 175 | this.cache.addItem(searchTerm, finalResults); 176 | res.json(finalResults); 177 | }); 178 | }); 179 | } 180 | private postVideosSearch(req: express.Request, res: express.Response): void { 181 | let searchTerm = req.body.searchTerm; 182 | 183 | console.log(`searching for ${searchTerm} locally`); 184 | 185 | if (searchTerm.startsWith("/yt")) { 186 | this.postVideosYouTubeSearch(req, res); 187 | } else if (searchTerm.startsWith("/group") || searchTerm.startsWith("/g")) { 188 | searchTerm = _.slice(searchTerm.split(" "), 1).join(" "); 189 | 190 | if (searchTerm === "all") { 191 | this.db.getAllVideosFromDB(data => { 192 | res.json(data); 193 | }); 194 | } else { 195 | this.db.getAllVideosForGroupFromDB(searchTerm, data => { 196 | res.json(data); 197 | }); 198 | } 199 | } else { 200 | this.db.getVideosWhereTitleLikeFromDB(searchTerm, data => { 201 | res.json(data); 202 | }); 203 | } 204 | } 205 | private postVideosAdd(req: express.Request, res: express.Response): void { 206 | let title = req.body.title, 207 | ytid = req.body.ytid, 208 | group = req.body.group; 209 | 210 | console.log(title, ytid, group); 211 | 212 | if (!(_.isEmpty(title) || _.isEmpty(ytid) || _.isEmpty(group))) { 213 | this.db.addVideoToDB(title, ytid, group); 214 | res.json({ success: true }); 215 | } else { 216 | res.json({ success: false }); 217 | } 218 | } 219 | private postVideosDelete(req: express.Request, res: express.Response): void { 220 | let ytid = req.body.ytid; 221 | 222 | if (ytid !== undefined && ytid.length > 0) { 223 | this.db.deleteVideoFromDB(ytid); 224 | 225 | res.json({ success: true }); 226 | } else { 227 | res.json({ success: true }); 228 | } 229 | } 230 | private postVideosUpdate(req: express.Request, res: express.Response): void { 231 | let title = req.body.title, 232 | ytid = req.body.ytid, 233 | group = req.body.group; 234 | 235 | if ( 236 | title !== undefined && 237 | title.length > 0 && 238 | ytid !== undefined && 239 | ytid.length > 0 && 240 | group !== undefined && 241 | group.length > 0 242 | ) { 243 | this.db.updateVideoFromDB(title, ytid, group); 244 | 245 | res.json({ success: true }); 246 | } else { 247 | res.json({ success: false }); 248 | } 249 | } 250 | private postVideosRecentlyPlayedAdd( 251 | req: express.Request, 252 | res: express.Response 253 | ): void { 254 | let title = req.body.title, 255 | ytid = req.body.ytid; 256 | 257 | if ( 258 | title !== undefined && 259 | title.length > 0 && 260 | ytid !== undefined && 261 | ytid.length > 0 262 | ) { 263 | // Recently Played is the last 30 (by default) videos played 264 | 265 | // get all of the recently played videos 266 | this.db.getAllVideosForGroupFromDB("Recently Played", data => { 267 | // If the video we are trying to add is already in the Recently Played 268 | // group then we need to exit gracefully... 269 | 270 | if (_.find(data, { ytid: ytid }) !== undefined) { 271 | let message = `${ytid} is already in the Recently Played group...`; 272 | console.log(message); 273 | res.json({ 274 | success: false, 275 | message: message 276 | }); 277 | } else { 278 | this.db.addVideoToDB(title, ytid, "Recently Played"); 279 | 280 | res.json({ success: true }); 281 | } 282 | }); 283 | } else { 284 | res.json({ 285 | success: false, 286 | message: "title is required but was empty or undefined" 287 | }); 288 | } 289 | } 290 | private postVideosRecentlyPlayedSearch( 291 | req: express.Request, 292 | res: express.Response 293 | ): void { 294 | let searchTerm = req.body.searchTerm; 295 | 296 | if (searchTerm !== undefined && searchTerm.length > 0) { 297 | this.db.getVideosFromGroupWhereTitleLikeFromDB( 298 | searchTerm, 299 | "Recently Played", 300 | data => { 301 | res.json(data); 302 | } 303 | ); 304 | } else { 305 | res.json([]); 306 | } 307 | } 308 | private postVideosRecentlyPlayedLas30( 309 | req: express.Request, 310 | res: express.Response 311 | ): void { 312 | let trim = false; 313 | 314 | if (req.body.trim !== undefined) { 315 | trim = req.body.trim; 316 | } 317 | 318 | // This is going to trim the recently played rows down to the max number 319 | // which defaults to 30 320 | this.db.getAllVideosForGroupFromDB("Recently Played", data => { 321 | // take top 30 322 | let top30RecentlyPlayed = _.takeRight( 323 | _.uniqBy(data, "ytid"), 324 | AppConfig.maxRecentlyPlayedVideos 325 | ); 326 | 327 | // console.log(`before: ${data.length}`); 328 | // console.log(`after: ${top30RecentlyPlayed.length}`); 329 | 330 | if (req.body.trim) { 331 | console.log("Trimming the Recently Played group"); 332 | // delete all recently played from db 333 | this.db.deleteRecentlyPlayedVideosFromDB(); 334 | // add trimmed recently played back to DB 335 | _.forEach(top30RecentlyPlayed, rp => { 336 | this.db.addVideoToDB(rp.title, rp.ytid, "Recently Played"); 337 | }); 338 | } 339 | 340 | res.json(top30RecentlyPlayed); 341 | }); 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | // config.js - App configuration information 2 | // Author(s): Frank Hale 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU General Public License 15 | // along with this program. If not, see . 16 | 17 | import * as path from "path"; 18 | import * as youtubeSearch from "youtube-search"; 19 | 20 | export default class AppConfig { 21 | static serverPort = "62374"; 22 | static socketIOPort = "62375"; 23 | static serverURL = `http://localhost:${AppConfig.serverPort}`; 24 | static maxSearchResults = 30; 25 | static maxRecentlyPlayedVideos = 30; 26 | static youtubeSearchOpts: youtubeSearch.YouTubeSearchOptions = { 27 | maxResults: AppConfig.maxSearchResults, 28 | key: process.env.YOUTUBE_API_KEY, 29 | type: "video" 30 | }; 31 | static dataPath = `${__dirname}${path.sep}..${path.sep}data`; 32 | static dataFilePath = `${AppConfig.dataPath}${path.sep}data.json`; 33 | } 34 | -------------------------------------------------------------------------------- /src/data.ts: -------------------------------------------------------------------------------- 1 | // data.ts - Default data to populate database with 2 | // Copyright (C) 2016-2017 Frank Hale 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU General Public License 15 | // along with this program. If not, see . 16 | 17 | import * as fs from "fs"; 18 | import AppConfig from "./config"; 19 | import { IVideoGroup } from "./infrastructure"; 20 | 21 | export default class DefaultData { 22 | static getData(): IVideoGroup[] { 23 | if (fs.existsSync(AppConfig.dataFilePath)) { 24 | // read in the data from the data.json this file is also the same 25 | // one that is created when using the archive function 26 | return JSON.parse(fs.readFileSync(AppConfig.dataFilePath).toString()); 27 | } else { 28 | return []; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | // db.ts - Database API for Toby 2 | // Author(s): Frank Hale 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU General Public License 15 | // along with this program. If not, see . 16 | 17 | import * as sqlite3 from "sqlite3"; 18 | import * as path from "path"; 19 | import * as _ from "lodash"; 20 | 21 | import AppConfig from "./config"; 22 | import { IVideoGroup } from "./infrastructure"; 23 | 24 | export default class DB { 25 | private db: sqlite3.Database; 26 | 27 | constructor() { 28 | sqlite3.verbose(); 29 | this.db = new sqlite3.Database(`${AppConfig.dataPath}${path.sep}videoDB`); 30 | } 31 | importIntoDB(videoData: IVideoGroup[]): void { 32 | this.db.serialize(() => { 33 | this.db.run( 34 | "CREATE TABLE IF NOT EXISTS videos (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, ytid TEXT, [group] TEXT)" 35 | ); 36 | 37 | this.db.each("SELECT COUNT(*) as count FROM videos", (err, row) => { 38 | _.forEach(videoData, (g: IVideoGroup) => { 39 | _.forEach(g.entries, e => { 40 | this.getVideoFromDB(e.ytid, row => { 41 | if (row === undefined && g.group !== "Recently Played") { 42 | console.log(`importing ${e.title} | ${g.group}`); 43 | this.db.run( 44 | "INSERT into videos(title,ytid,[group]) VALUES (?,?,?)", 45 | [e.title, e.ytid, g.group] 46 | ); 47 | } 48 | }); 49 | }); 50 | }); 51 | }); 52 | }); 53 | } 54 | getAllYTIDsFromDB(finished: (rows: any[]) => void): void { 55 | this.db.all( 56 | "SELECT title, ytid, [group] FROM videos WHERE [group] IS NOT 'Recently Played' COLLATE NOCASE", 57 | (_err, rows) => { 58 | if (_.isFunction(finished)) { 59 | // let ytids: string[] = []; 60 | 61 | // _.forEach(rows, d => { 62 | // ytids.push(d.ytid); 63 | // }); 64 | 65 | let ytids: string[] = _.map(rows, d => { 66 | return d.ytid; 67 | }); 68 | 69 | finished(ytids); 70 | } 71 | } 72 | ); 73 | } 74 | getAllVideosFromDB(finished: (rows: any[]) => void): void { 75 | this.db.all( 76 | "SELECT title, ytid, [group] FROM videos WHERE [group] IS NOT 'Recently Played' COLLATE NOCASE", 77 | (_err, rows) => { 78 | if (_.isFunction(finished)) { 79 | let _rows = _.forEach(rows, d => { 80 | d.isArchived = true; 81 | }); 82 | 83 | finished(_rows); 84 | } 85 | } 86 | ); 87 | } 88 | getAllVideosOrderedByGroupDB(finished: (rows: any[]) => void): void { 89 | this.db.all( 90 | "SELECT title, ytid, [group] FROM videos ORDER BY [group]", 91 | (_err, rows) => { 92 | if (_.isFunction(finished)) { 93 | finished(rows); 94 | } 95 | } 96 | ); 97 | } 98 | getVideoFromDB(ytid: string, finished: (row: any) => void): void { 99 | this.db.get( 100 | "SELECT title, ytid, [group] FROM videos WHERE ytid = ? AND [group] IS NOT 'Recently Played'", 101 | [ytid], 102 | (_err, row) => { 103 | if (_.isFunction(finished)) { 104 | finished(row); 105 | } 106 | } 107 | ); 108 | } 109 | getAllGroupsFromDB(finished: (rows: any[]) => void): void { 110 | this.db.all("SELECT DISTINCT [group] FROM videos", (err, rows) => { 111 | if (_.isFunction(finished)) { 112 | finished(rows); 113 | } 114 | }); 115 | } 116 | getAllVideosWhereYTIDInList( 117 | ytids: string[], 118 | finished: (rows: any[]) => void 119 | ): void { 120 | let ytids_in_string = _.map(ytids, r => { 121 | return `'${r}'`; 122 | }).join(","); 123 | 124 | this.db.all( 125 | `SELECT title, ytid, [group] FROM videos WHERE [group] IS NOT 'Recently Played' AND ytid IN (${ytids_in_string})`, 126 | (_err, ytids_found) => { 127 | finished(ytids_found); 128 | } 129 | ); 130 | } 131 | getAllVideosForGroupFromDB( 132 | group: string, 133 | finished: (rows: any[]) => void 134 | ): void { 135 | // this.db.all("SELECT title, ytid, [group] FROM videos WHERE [group] = ? COLLATE NOCASE", [group], (err, rows) => { 136 | // if (finished !== undefined) { 137 | // let _rows = _.forEach(rows, (d) => { 138 | // d.isArchived = true; 139 | // }); 140 | 141 | // finished(_rows); 142 | // } 143 | // }); 144 | 145 | this.db.all( 146 | "SELECT title, ytid, [group] FROM videos WHERE [group] = ? COLLATE NOCASE", 147 | [group], 148 | (err, rows) => { 149 | if (_.isFunction(finished)) { 150 | let ytids = _.map(rows, r => { 151 | return r.ytid; 152 | }), 153 | ytids_in_string = _.map(ytids, r => { 154 | return `'${r}'`; 155 | }).join(","); 156 | 157 | this.db.all( 158 | `SELECT ytid FROM videos WHERE [group] IS NOT 'Recently Played' AND ytid IN (${ytids_in_string})`, 159 | (_err, ytids_found) => { 160 | let _ytids = _.map(ytids_found, r => { 161 | return r.ytid; 162 | }), 163 | _rows = _.forEach(rows, d => { 164 | d.isArchived = 165 | _.indexOf(_ytids, d.ytid) !== -1 ? true : false; 166 | }); 167 | 168 | finished(_rows); 169 | } 170 | ); 171 | } 172 | } 173 | ); 174 | } 175 | getVideosWhereTitleLikeFromDB( 176 | searchTerm: string, 177 | finished: (rows: any[]) => void 178 | ): void { 179 | searchTerm = `%${searchTerm.trim()}%`; 180 | 181 | this.db.all( 182 | "SELECT title, ytid, [group] FROM videos WHERE title LIKE ? AND [group] IS NOT 'Recently Played' COLLATE NOCASE", 183 | [searchTerm], 184 | (_err, rows) => { 185 | if (_.isFunction(finished)) { 186 | let _rows = _.forEach(rows, d => { 187 | d.isArchived = true; 188 | }); 189 | 190 | finished(_rows); 191 | } 192 | } 193 | ); 194 | } 195 | getVideosFromGroupWhereTitleLikeFromDB( 196 | searchTerm: string, 197 | group: string, 198 | finished: (rows: any[]) => void 199 | ): void { 200 | searchTerm = `%${searchTerm.trim()}%`; 201 | 202 | this.db.all( 203 | "SELECT title, ytid, [group] FROM videos WHERE title LIKE ? AND [group] = ? COLLATE NOCASE", 204 | [searchTerm, group], 205 | (err, rows) => { 206 | if (_.isFunction(finished)) { 207 | let _rows = _.forEach(rows, d => { 208 | d.isArchived = true; 209 | }); 210 | 211 | finished(_rows); 212 | } 213 | } 214 | ); 215 | } 216 | addVideoToDB(title: string, ytid: string, group: string): void { 217 | if (!(_.isEmpty(title) || _.isEmpty(ytid) || _.isEmpty(group))) { 218 | this.db.get( 219 | "SELECT ytid FROM videos WHERE ytid = ? AND [group] = ? COLLATE NOCASE", 220 | [ytid, group], 221 | (_err, rows) => { 222 | if (_.isEmpty(rows)) { 223 | console.log(`inserting ${title} into ${group}`); 224 | this.db.run( 225 | "INSERT into videos(title,ytid,[group]) VALUES (?,?,?)", 226 | [title, ytid, group] 227 | ); 228 | } 229 | } 230 | ); 231 | } 232 | } 233 | deleteVideoFromDB(ytid: string): void { 234 | this.db.get( 235 | "SELECT ytid FROM videos WHERE ytid = ?", 236 | [ytid], 237 | (_err, rows) => { 238 | if (!_.isEmpty(rows)) { 239 | this.db.run("DELETE FROM videos WHERE ytid = ?", [ytid]); 240 | } 241 | } 242 | ); 243 | } 244 | updateVideoFromDB(title: string, ytid: string, group: string): void { 245 | if (!_.isEmpty(title) && !_.isEmpty(group)) { 246 | this.db.get( 247 | "SELECT ytid FROM videos WHERE ytid = ?", 248 | [ytid], 249 | (_err, rows) => { 250 | if (_.isEmpty(rows)) { 251 | this.db.run( 252 | "UPDATE videos SET title = ?, group = ? WHERE ytid = ?", 253 | [title, group, ytid] 254 | ); 255 | } 256 | } 257 | ); 258 | } 259 | } 260 | deleteRecentlyPlayedVideosFromDB(): void { 261 | this.db.run("DELETE FROM videos WHERE [group] = 'Recently Played'"); 262 | } 263 | close(): void { 264 | this.db.close(); 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/electron.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // electron.ts - Common interfaces used by Toby's server. 4 | // Author(s): Frank Hale 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License as published by 8 | // the Free Software Foundation, either version 3 of the License, or 9 | // (at your option) any later version. 10 | // 11 | // This program is distributed in the hope that it will be useful, 12 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | // GNU General Public License for more details. 15 | // 16 | // You should have received a copy of the GNU General Public License 17 | // along with this program. If not, see . 18 | 19 | // NOTE: 20 | // 21 | // We are including the official Electron type definition here because we don't 22 | // explicitly install the Electron package since Toby can run on whatever the 23 | // user wants to use eg. NW.js, Electron or just the web. Additionally 24 | // @types/electron was removed from package.json as it appears to no longer be 25 | // updated and installing it will show an warning when compiling via Grunt. 26 | 27 | import * as path from "path"; 28 | import { app, BrowserWindow } from "electron"; 29 | 30 | let mainWindow: Electron.BrowserWindow; 31 | 32 | // found an issue with recent versions of electron in that focus would run crazy 33 | // in certain situations. 34 | // 35 | // issue: https://github.com/electron/electron/issues/7655 36 | // 37 | // this command line switch seems to make the problem go away 38 | app.commandLine.appendSwitch("enable-use-zoom-for-dsf", "false"); 39 | 40 | function createWindow(): void { 41 | mainWindow = new BrowserWindow({ 42 | fullscreenable: true, 43 | autoHideMenuBar: true, 44 | useContentSize: true, 45 | icon: `${__dirname}${path.sep}..${path.sep}public${path.sep}images${path.sep}toby.png`, 46 | backgroundColor: "#000", 47 | width: 640, 48 | height: 400, 49 | minWidth: 640, 50 | minHeight: 400, 51 | show: false, 52 | webPreferences: { 53 | nodeIntegration: true, 54 | webviewTag: true 55 | } 56 | }); 57 | mainWindow.setMenu(null); 58 | mainWindow.loadURL(`file://${__dirname}/index.html`); 59 | mainWindow.webContents.on("did-finish-load", () => { 60 | mainWindow.show(); 61 | // mainWindow.webContents.openDevTools(); 62 | }); 63 | mainWindow.on("closed", (e: any) => { 64 | mainWindow = null; 65 | }); 66 | } 67 | app.on("ready", createWindow); 68 | app.on("window-all-closed", () => { 69 | if (process.platform !== "darwin") { 70 | app.quit(); 71 | } 72 | }); 73 | app.on("activate", () => { 74 | if (mainWindow === null) { 75 | createWindow(); 76 | } 77 | }); 78 | -------------------------------------------------------------------------------- /src/infrastructure.ts: -------------------------------------------------------------------------------- 1 | // infrastructure.ts - Common interfaces used by Toby's server. 2 | // Author(s): Frank Hale 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU General Public License 15 | // along with this program. If not, see . 16 | 17 | export interface IVideoGroup { 18 | group: string; 19 | entries: IVideoEntry[]; 20 | } 21 | 22 | export interface IVideoEntry { 23 | title: string; 24 | ytid: string; 25 | group?: string; 26 | isArchived?: boolean; 27 | } 28 | -------------------------------------------------------------------------------- /src/platform.ts: -------------------------------------------------------------------------------- 1 | // platform.js - Platform specific code for Toby 2 | // Author(s): Frank Hale 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU General Public License 15 | // along with this program. If not, see . 16 | 17 | import * as stream from "stream"; 18 | import { spawn, ChildProcess } from "child_process"; 19 | import * as _ from "lodash"; 20 | import * as request from "request"; 21 | import AppConfig from "./config"; 22 | 23 | const titleCase = require("title-case"); 24 | const pkgJSON = require("../package.json"); 25 | // const ioHook = require("iohook"); 26 | 27 | class Platform { 28 | private node: ChildProcess; 29 | private $content: JQuery; 30 | private $webview: JQuery; 31 | private webview: any; 32 | private snapToPlayerCodeBlock: string; 33 | private socket: SocketIO.Server; 34 | private serverLog: String[]; 35 | 36 | static bootstrap() { 37 | return new Platform(); 38 | } 39 | 40 | constructor() { 41 | this.node = spawn( 42 | ".\\node_modules\\node\\bin\\node.exe", 43 | ["./build/server.js"], 44 | { 45 | cwd: process.cwd() 46 | } 47 | ); 48 | this.$content = $("#content"); 49 | this.$webview = $("#webview"); 50 | this.webview = this.$webview[0]; 51 | this.serverLog = []; 52 | 53 | document.title = pkgJSON.title; 54 | 55 | this.socket = require("socket.io")(AppConfig.socketIOPort); 56 | this.socket.on("connection", s => { 57 | this.$content.append("Socket.IO connection established...
"); 58 | 59 | s.on("title", (t: string) => { 60 | if (t !== undefined && t !== "") { 61 | this.$content.append(`setting title to: ${t}
`); 62 | document.title = t; 63 | } 64 | }); 65 | 66 | s.on("toggle-server-log", () => { 67 | this.f1Handler(); 68 | }); 69 | 70 | s.on("toggle-fullscreen", () => { 71 | this.f11Handler(); 72 | }); 73 | 74 | s.on("get-server-log", () => { 75 | s.emit("server-log", { log: this.serverLog }); 76 | }); 77 | 78 | s.emit("toby-version", { 79 | title: pkgJSON.title, 80 | version: `${titleCase(pkgJSON.name)}-${pkgJSON.version}` 81 | }); 82 | }); 83 | 84 | this.snapToPlayerCodeBlock = `var actualCode = '(' + function() { 85 | snapToPlayer(); 86 | } + ')();'; 87 | var script = document.createElement('script'); 88 | script.textContent = actualCode; 89 | (document.head||document.documentElement).appendChild(script); 90 | script.parentNode.removeChild(script); 91 | `; 92 | 93 | this.redirectOutput(this.node.stdout); 94 | this.redirectOutput(this.node.stderr); 95 | 96 | let checkServerRunning = setInterval(() => { 97 | request(AppConfig.serverURL, (error, response, body) => { 98 | if (!error && response.statusCode === 200) { 99 | this.$webview.attr("src", AppConfig.serverURL); 100 | $("#loading").css("display", "none"); 101 | this.$webview.css("display", "block"); 102 | clearInterval(checkServerRunning); 103 | } 104 | }); 105 | }, 1000); 106 | this.setup(); 107 | } 108 | private setup(): void { 109 | key("f1", this.f1Handler); 110 | key("f11", this.f11Handler); 111 | 112 | if (navigator.userAgent.indexOf("node-webkit") > -1) { 113 | let win = nw.Window.get(); 114 | 115 | win.on("loaded", () => { 116 | // win.showDevTools(); 117 | win.show(); 118 | }); 119 | 120 | win.on("restore", () => { 121 | this.webview.executeScript({ code: this.snapToPlayerCodeBlock }); 122 | }); 123 | 124 | win.on("new-win-policy", (_frame, _url, policy) => { 125 | policy.ignore(); 126 | }); 127 | 128 | win.on("close", () => { 129 | win.hide(); 130 | 131 | this.$webview.remove(); 132 | 133 | $.ajax({ 134 | type: "POST", 135 | url: "/api/app/close", 136 | async: false 137 | }); 138 | 139 | win.close(true); 140 | }); 141 | 142 | this.webview.addEventListener( 143 | "newwindow", 144 | this.newWindowHandler.bind(this) 145 | ); 146 | } 147 | 148 | window.addEventListener("resize", e => { 149 | this.resizeContent(); 150 | }); 151 | 152 | this.resizeContent(); 153 | 154 | if ( 155 | navigator.userAgent.indexOf("node-webkit") > -1 || 156 | navigator.userAgent.indexOf("Electron") > -1 157 | ) { 158 | this.webview.addEventListener("permissionrequest", (e: any) => { 159 | if (e.permission === "fullscreen") { 160 | e.request.allow(); 161 | } 162 | }); 163 | } 164 | 165 | if (navigator.userAgent.indexOf("Electron") > -1) { 166 | this.webview.addEventListener( 167 | "new-window", 168 | this.newWindowHandler.bind(this) 169 | ); 170 | 171 | window.addEventListener("beforeunload", () => { 172 | $.ajax({ 173 | type: "POST", 174 | url: "/api/app/close", 175 | async: false 176 | }); 177 | }); 178 | 179 | // this.webview.addEventListener("dom-ready", () => { 180 | // this.webview.openDevTools(); 181 | // }); 182 | 183 | let browserWindow = require("electron").remote.getCurrentWindow(); 184 | 185 | this.webview.addEventListener("enter-html-full-screen", () => { 186 | if (!browserWindow.isFullScreen()) { 187 | browserWindow.setFullScreen(true); 188 | } 189 | }); 190 | 191 | browserWindow.on("leave-full-screen", () => { 192 | this.webview.executeJavaScript(this.snapToPlayerCodeBlock); 193 | }); 194 | } 195 | } 196 | private resizeContent(): void { 197 | this.$content.css("width", window.innerWidth - 20); 198 | this.$content.css("height", window.innerHeight - 20); 199 | } 200 | private strip(s: string): string { 201 | // regex from: http://stackoverflow.com/a/29497680/170217 202 | return s.replace( 203 | /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, 204 | "" 205 | ); 206 | } 207 | private redirectOutput(x: stream.Readable): void { 208 | let lineBuffer = ""; 209 | 210 | x.on("data", data => { 211 | lineBuffer += data.toString(); 212 | let lines = lineBuffer.split("\n"); 213 | 214 | _.forEach(lines, l => { 215 | if (l !== "") { 216 | // console.log(this.strip(l)); 217 | let strippedData = this.strip(l); 218 | this.$content.append(`${strippedData}
`); 219 | this.serverLog.push(strippedData); 220 | } 221 | }); 222 | 223 | lineBuffer = lines[lines.length - 1]; 224 | }); 225 | } 226 | private newWindowHandler(e: any): void { 227 | // Looks like we can differentiate between clicking the YouTube icon 228 | // in the player where we want it to open an external browser and clicking 229 | // a suggested video link after a video is played. 230 | // 231 | // When clicking the YouTube link "time_continue" is present in the url. 232 | // {url: "https://www.youtube.com/watch?time_continue=1&v=ctrZdbExVrk"} 233 | // 234 | // When clicking on a suggested video the link is just an ordinary YouTube 235 | // video link with video ID. 236 | // {url: "https://www.youtube.com/watch?v=4nYMdMtGsPo"} 237 | 238 | // NOTE: What I said above is only partially true, the video has to start 239 | // playing for the time_continue to be present in the URL. You cannot 240 | // click the YouTube link and have it open an external browser if the 241 | // video has not started to play. 242 | 243 | e.preventDefault(); 244 | 245 | const url = e.targetUrl || e.url; 246 | 247 | if (url.indexOf("?v=") > -1) { 248 | // the id extraction is almost verbatim from: 249 | // http://stackoverflow.com/a/3452617/170217 250 | let video_id = url.split("v=")[1]; 251 | let ampersandPosition = video_id.indexOf("&"); 252 | if (ampersandPosition !== -1) { 253 | video_id = video_id.substring(0, ampersandPosition); 254 | } 255 | // ------------------------------------------ 256 | 257 | this.$content.append(`emitting: play-video for ${video_id}
`); 258 | this.socket.emit("play-video", video_id); 259 | } else { 260 | if (navigator.userAgent.indexOf("node-webkit") > -1) { 261 | nw.Shell.openExternal(url); 262 | } else if (navigator.userAgent.indexOf("Electron") > -1) { 263 | const { shell } = require("electron"); 264 | shell.openExternal(url); 265 | } 266 | } 267 | } 268 | private f1Handler(): void { 269 | let $content = $("#content"), 270 | $webview = $("#webview"); 271 | 272 | if ($content.css("visibility") === "hidden") { 273 | $content.css("visibility", "visible"); 274 | $webview.css("visibility", "hidden"); 275 | } else { 276 | $content.css("visibility", "hidden"); 277 | $webview.css("visibility", "visible"); 278 | } 279 | } 280 | private f11Handler(): void { 281 | if (navigator.userAgent.indexOf("node-webkit") > -1) { 282 | let win = nw.Window.get(); 283 | 284 | if (win.isFullscreen) { 285 | win.leaveFullscreen(); 286 | } else { 287 | win.enterFullscreen(); 288 | } 289 | } else if (navigator.userAgent.indexOf("Electron") > -1) { 290 | const browserWindow = require("electron").remote.getCurrentWindow(); 291 | 292 | if (browserWindow.isFullScreen()) { 293 | browserWindow.setFullScreen(false); 294 | this.webview.executeJavaScript(this.snapToPlayerCodeBlock); 295 | } else { 296 | browserWindow.setFullScreen(true); 297 | } 298 | } 299 | } 300 | } 301 | 302 | $(document).ready(function() { 303 | Platform.bootstrap(); 304 | }); 305 | -------------------------------------------------------------------------------- /src/react-components/command-input-ui.tsx: -------------------------------------------------------------------------------- 1 | // command-input-ui.tsx - The command line input component for Toby 2 | // Author(s): Frank Hale 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU General Public License 15 | // along with this program. If not, see . 16 | 17 | import * as React from "react"; 18 | import * as _ from "lodash"; 19 | import * as $ from "jquery"; 20 | 21 | enum Keys { 22 | Enter = 13, 23 | Up = 38, 24 | Down = 40 25 | } 26 | 27 | interface ICommandInputState { 28 | commandIndex?: number; 29 | commandsEntered?: string[]; 30 | commandText?: JQuery; 31 | } 32 | 33 | export interface ICommandInputProps { 34 | onKeyEnter?: (event: any) => void; 35 | onKeyChanged?: (event: any) => void; 36 | placeHolder: string; 37 | } 38 | 39 | export class CommandInput extends React.Component< 40 | ICommandInputProps, 41 | ICommandInputState 42 | > { 43 | constructor(props: any) { 44 | super(props); 45 | 46 | this.onCommandInputKeyUp = this.onCommandInputKeyUp.bind(this); 47 | this.onCommandInputChanged = this.onCommandInputChanged.bind(this); 48 | 49 | this.state = { 50 | commandIndex: -1, 51 | commandsEntered: [] 52 | }; 53 | } 54 | componentDidMount() { 55 | const $commandText = $("#commandText"), 56 | resizeCommandInput = (): void => { 57 | $commandText.width(window.innerWidth - 50); 58 | }; 59 | 60 | $(window).resize(e => { 61 | resizeCommandInput(); 62 | }); 63 | 64 | resizeCommandInput(); 65 | 66 | this.setState({ commandText: $commandText }); 67 | } 68 | private onCommandInputKeyUp(e: any): void { 69 | if (e.which === Keys.Up) { 70 | let commandIndex = 71 | this.state.commandIndex === -1 72 | ? this.state.commandsEntered.length - 1 73 | : this.state.commandIndex - 1; 74 | 75 | if (commandIndex < 0) { 76 | commandIndex = 0; 77 | } 78 | 79 | this.setState({ commandIndex: commandIndex }, () => { 80 | this.state.commandText.val(this.state.commandsEntered[commandIndex]); 81 | }); 82 | } else if (e.which === Keys.Down) { 83 | let commandIndex = 84 | this.state.commandIndex === -1 ? 0 : this.state.commandIndex + 1; 85 | 86 | if (commandIndex > this.state.commandsEntered.length) { 87 | commandIndex = this.state.commandsEntered.length; 88 | } 89 | 90 | this.setState({ commandIndex: commandIndex }, () => { 91 | this.state.commandText.val(this.state.commandsEntered[commandIndex]); 92 | }); 93 | } else if (e.which === Keys.Enter) { 94 | const textEntered = this.state.commandText.val() as string; 95 | if (!(textEntered.length > 0)) return; 96 | 97 | this.setState( 98 | { 99 | commandsEntered: _.uniq( 100 | this.state.commandsEntered.concat([textEntered]) 101 | ), 102 | commandIndex: -1 103 | }, 104 | () => { 105 | if (this.props.onKeyEnter !== undefined) { 106 | this.props.onKeyEnter(textEntered); 107 | } 108 | } 109 | ); 110 | } 111 | } 112 | private onCommandInputChanged(e: any): void { 113 | if (this.props.onKeyChanged !== undefined) { 114 | this.props.onKeyChanged(this.state.commandText.val()); 115 | } 116 | } 117 | render() { 118 | return ( 119 |
120 | > 129 |
130 | ); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/react-components/dropdown-ui.tsx: -------------------------------------------------------------------------------- 1 | // dropdown-ui.tsx 2 | // Author(s): Frank Hale 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU General Public License 15 | // along with this program. If not, see . 16 | 17 | import * as React from "react"; 18 | import * as $ from "jquery"; 19 | import * as _ from "lodash"; 20 | 21 | export interface IDropDownItem { 22 | name: string; 23 | value: string; 24 | action(): void; 25 | } 26 | 27 | interface IDropDownProps { 28 | name: string; 29 | items: IDropDownItem[]; 30 | onDropDownChange?: (value: string, id: JQuery) => void; 31 | disabled?: boolean; 32 | selected?: string; 33 | style?: {}; 34 | className?: string; 35 | } 36 | 37 | interface IDropDownState { 38 | name?: string; 39 | items?: IDropDownItem[]; 40 | onDropDownChange?: (value: string, id: JQuery) => void; 41 | disabled?: boolean; 42 | selected?: string; 43 | } 44 | 45 | export class DropDown extends React.Component { 46 | constructor(props: any) { 47 | super(props); 48 | 49 | this.onDropDownChange = this.onDropDownChange.bind(this); 50 | 51 | this.state = { 52 | name: "", 53 | items: [] 54 | }; 55 | } 56 | 57 | static getDerivedStateFromProps( 58 | props: IDropDownProps, 59 | state: IDropDownState 60 | ): IDropDownState { 61 | if (!(_.isEmpty(props.name) && _.isEmpty(props.items))) { 62 | return { 63 | name: props.name, 64 | items: props.items, 65 | disabled: props.disabled === undefined ? false : true, 66 | selected: props.selected, 67 | onDropDownChange: 68 | props.onDropDownChange !== undefined 69 | ? props.onDropDownChange 70 | : () => {} 71 | }; 72 | } 73 | 74 | return null; 75 | } 76 | 77 | private onDropDownChange(e: any): void { 78 | if (this.state.onDropDownChange !== undefined) { 79 | this.state.onDropDownChange(e.target.value, $(e.target).prop("id")); 80 | } 81 | } 82 | render() { 83 | let renderedItems = _.map(this.state.items, (e: any, i) => { 84 | return ( 85 | 88 | ); 89 | }); 90 | 91 | return ( 92 | 103 | ); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/react-components/infrastructure.ts: -------------------------------------------------------------------------------- 1 | // infrastructure.ts - Miscellaneous interfaces and/or other things needed by 2 | // multiple files in Toby 3 | // Author(s): Frank Hale 4 | // 5 | // This program is free software: you can redistribute it and/or modify 6 | // it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or 8 | // (at your option) any later version. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | export interface IVideoGroup { 19 | group: string; 20 | entries: IVideoEntry[]; 21 | } 22 | 23 | export interface IVideoEntry { 24 | title: string; 25 | ytid: string; 26 | group?: string; 27 | isArchived?: boolean; 28 | justAdded?: boolean; 29 | } 30 | 31 | export interface ITobyVersionInfo { 32 | title: string; 33 | version: string; 34 | } 35 | 36 | export interface ISearchResults { 37 | playVideo: (video: IVideoEntry, data: IVideoGroup[]) => void; 38 | title: string; 39 | ytid: string; 40 | group: string; 41 | thumbnail: string; 42 | isArchived: boolean; 43 | justAdded?: boolean; 44 | } 45 | -------------------------------------------------------------------------------- /src/react-components/server-log-ui.tsx: -------------------------------------------------------------------------------- 1 | // server-log-ui.tsx - Server log React component for Toby 2 | // Author(s): Frank Hale 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU General Public License 15 | // along with this program. If not, see . 16 | 17 | import * as React from "react"; 18 | import * as _ from "lodash"; 19 | 20 | export interface IServerLogProps { 21 | display: boolean; 22 | log: String[]; 23 | } 24 | 25 | interface IServerLogState { 26 | display: boolean; 27 | log: String[]; 28 | } 29 | 30 | export class ServerLog extends React.Component< 31 | IServerLogProps, 32 | IServerLogState 33 | > { 34 | constructor(props: IServerLogProps) { 35 | super(props); 36 | 37 | this.state = { 38 | display: false, 39 | log: [] 40 | }; 41 | } 42 | 43 | static getDerivedStateFromProps( 44 | props: IServerLogProps, 45 | state: IServerLogState 46 | ): IServerLogState { 47 | if (props.display !== undefined && props.log !== undefined) { 48 | return { 49 | display: props.display, 50 | log: props.log 51 | }; 52 | } 53 | 54 | return null; 55 | } 56 | 57 | render() { 58 | if (this.state.display && !_.isEmpty(this.state.log)) { 59 | return ( 60 |
61 | {this.state.log.map((l, i) => { 62 | return
{l}
; 63 | })} 64 |
65 | ); 66 | } 67 | 68 | return null; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/react-components/toby-ui.tsx: -------------------------------------------------------------------------------- 1 | // toby-ui.tsx - Front end code React component for Toby 2 | // Author(s): Frank Hale 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU General Public License 15 | // along with this program. If not, see . 16 | 17 | import * as React from "react"; 18 | import * as ReactDOM from "react-dom"; 19 | import * as _ from "lodash"; 20 | import * as io from "socket.io-client"; 21 | import * as $ from "jquery"; 22 | 23 | import { CommandInput } from "./command-input-ui"; 24 | import { YouTube } from "./youtube-ui"; 25 | import { Version } from "./version-ui"; 26 | import { VideoListGrid } from "./video-list-grid-ui"; 27 | import { VideoList } from "./video-list-ui"; 28 | import { 29 | IVideoGroup, 30 | IVideoEntry, 31 | ITobyVersionInfo, 32 | ISearchResults 33 | } from "./infrastructure"; 34 | 35 | interface ITobyState { 36 | videoData?: IVideoGroup[]; 37 | searchResults?: ISearchResults[]; 38 | applyFilter?: string; 39 | currentVideo?: IVideoEntry; 40 | groups?: string[]; 41 | gridView?: boolean; 42 | manage?: boolean; 43 | tobyVersionInfo?: ITobyVersionInfo; 44 | } 45 | 46 | interface ICommand { 47 | commands: string[]; 48 | description: string; 49 | action: (searchTerm: string, commandSegments: string[]) => void; 50 | } 51 | 52 | export class Toby extends React.Component<{}, ITobyState> { 53 | private socket: SocketIOClient.Socket; 54 | private commands: ICommand[]; 55 | 56 | constructor(props: any) { 57 | super(props); 58 | 59 | this.onCommandEntered = this.onCommandEntered.bind(this); 60 | this.onAddVideoButtonClick = this.onAddVideoButtonClick.bind(this); 61 | this.onUpdateVideoButtonClick = this.onUpdateVideoButtonClick.bind(this); 62 | this.onDeleteVideoButtonClick = this.onDeleteVideoButtonClick.bind(this); 63 | 64 | this.state = { 65 | videoData: [], 66 | searchResults: [], 67 | applyFilter: "", 68 | gridView: false, 69 | manage: false, 70 | tobyVersionInfo: { title: "", version: "" } 71 | }; 72 | 73 | // this.socket = 74 | // navigator.userAgent.includes("node-webkit") || navigator.userAgent.includes("Electron") 75 | // ? io("http://localhost:62375") 76 | // : undefined; 77 | this.socket = io("http://localhost:62375"); 78 | 79 | this.setupCommands(); 80 | } 81 | componentDidMount() { 82 | if (this.socket !== undefined) { 83 | this.socket.on("toby-version", (versionInfo: ITobyVersionInfo): void => { 84 | this.setState({ 85 | tobyVersionInfo: { 86 | title: versionInfo.title, 87 | version: versionInfo.version 88 | } 89 | }); 90 | 91 | document.title = versionInfo.title; 92 | }); 93 | 94 | key("f1", () => { 95 | this.socket.emit("toggle-server-log"); 96 | }); 97 | 98 | key("f11", () => { 99 | this.socket.emit("toggle-fullscreen"); 100 | }); 101 | 102 | // User clicked on a recommended video at the end of playing a video 103 | this.socket.on("play-video", (ytid: string) => { 104 | this.playVideo({ title: "", ytid: ytid }); 105 | }); 106 | } 107 | 108 | $.ajax({ 109 | url: "/api/videos/groups" 110 | }).done(data => { 111 | this.setState({ 112 | groups: data 113 | }); 114 | }); 115 | } 116 | private setVideoResultsState( 117 | data: IVideoEntry[], 118 | manage: boolean = false 119 | ): void { 120 | this.setState({ 121 | searchResults: this.buildVideoResults(data), 122 | manage 123 | }); 124 | } 125 | private performSearch(searchTerm: string, url: string): void { 126 | // "/api/videos/search" 127 | // "/api/videos/youtube/search" 128 | 129 | $.post({ 130 | url: url, 131 | data: { searchTerm: searchTerm } 132 | }).done((data: IVideoEntry[]) => { 133 | this.setVideoResultsState(data); 134 | }); 135 | } 136 | private buildVideoResults(data: IVideoEntry[]): ISearchResults[] { 137 | let results: ISearchResults[] = []; 138 | 139 | _.forEach(data, v => { 140 | // Image thumbnail URL looks like this: 141 | // 142 | // https://i.ytimg.com/vi/YTID/default.jpg 143 | // https://i.ytimg.com/vi/YTID/mqdefault.jpg 144 | // https://i.ytimg.com/vi/YTID/hqdefault.jpg 145 | 146 | results.push({ 147 | // player: this.state.player, 148 | playVideo: this.playVideo.bind(this), 149 | title: v.title, 150 | ytid: v.ytid, 151 | group: v.group, 152 | thumbnail: `https://i.ytimg.com/vi/${v.ytid}/default.jpg`, 153 | isArchived: v.isArchived 154 | }); 155 | }); 156 | 157 | return _.sortBy(results, "title"); 158 | } 159 | private setupCommands(): void { 160 | this.commands = [ 161 | { 162 | commands: ["/g", "/group"], 163 | description: "List all local videos in group.", 164 | action: (searchTerm, _commandSegments) => { 165 | this.performSearch(searchTerm, "/api/videos/search"); 166 | } 167 | }, 168 | { 169 | commands: ["/ls", "/loc", "/local"], 170 | description: "Search local videos saved in the database.", 171 | action: (_searchTerm, commandSegments) => { 172 | this.performSearch( 173 | _.slice(commandSegments, 1).join(" "), 174 | "/api/videos/search" 175 | ); 176 | } 177 | }, 178 | { 179 | commands: ["/archive"], 180 | description: "Archive the database into a JSON file.", 181 | action: (_searchTerm, _commandSegments) => { 182 | $.get({ 183 | url: "/api/videos/archive" 184 | }); 185 | } 186 | }, 187 | { 188 | commands: ["/list", "/list-all"], 189 | description: "List all the videos in the database.", 190 | action: (_searchTerm, _commandSegments) => { 191 | $.get({ 192 | url: "/api/videos" 193 | }).done((data: IVideoEntry[]) => { 194 | this.setVideoResultsState(data); 195 | }); 196 | } 197 | }, 198 | { 199 | commands: ["/cls", "/clear"], 200 | description: "Clear the current search results.", 201 | action: (_searchTerm, _commandSegments) => { 202 | this.setState({ 203 | searchResults: [], 204 | currentVideo: { title: "", ytid: "" }, 205 | applyFilter: "" 206 | }); 207 | 208 | document.title = this.state.tobyVersionInfo.title; 209 | 210 | if (this.socket !== undefined) { 211 | this.socket.emit("title", this.state.tobyVersionInfo.title); 212 | } 213 | } 214 | }, 215 | { 216 | commands: ["/gv", "/grid-view"], 217 | description: "Switch to the grid view for listing videos.", 218 | action: (_searchTerm, _commandSegments) => { 219 | this.setState({ gridView: true }); 220 | } 221 | }, 222 | { 223 | commands: ["/dv", "/default-view"], 224 | description: "Switch to the default view for listing videos.", 225 | action: (_searchTerm, _commandSegments) => { 226 | this.setState({ gridView: false }); 227 | } 228 | }, 229 | { 230 | commands: ["/mc", "/monochrome"], 231 | description: "Switch the thumbnails and video to a monochome filter.", 232 | action: (_searchTerm, _commandSegments) => { 233 | this.setState({ applyFilter: "grayscale" }); 234 | } 235 | }, 236 | { 237 | commands: ["/sat", "/saturate"], 238 | description: "Switch the thumbnails and video to a saturated filter.", 239 | action: (_searchTerm, _commandSegments) => { 240 | this.setState({ applyFilter: "saturate" }); 241 | } 242 | }, 243 | { 244 | commands: ["/sep", "/sepia"], 245 | description: "Switch the thumbnails and video to a sepia filter.", 246 | action: (_searchTerm, _commandSegments) => { 247 | this.setState({ applyFilter: "sepia" }); 248 | } 249 | }, 250 | { 251 | commands: ["/norm", "/normal"], 252 | description: 253 | "Remove user set filters and return thumbnails and video to a normal filter.", 254 | action: (_searchTerm, _commandSegments) => { 255 | this.setState({ applyFilter: "normal" }); 256 | } 257 | }, 258 | { 259 | commands: ["/history"], 260 | description: "List all recently played videos.", 261 | action: (_searchTerm, _commandSegments) => { 262 | this.performSearch("/g Recently Played", "/api/videos/search"); 263 | } 264 | }, 265 | { 266 | commands: ["/rp", "/recently-played"], 267 | description: "List only the last 30 recently played videos.", 268 | action: (_searchTerm, _commandSegments) => { 269 | $.post({ 270 | url: "/api/videos/recently-played/last30" 271 | }).done((data: IVideoEntry[]) => { 272 | this.setVideoResultsState(data); 273 | }); 274 | } 275 | }, 276 | { 277 | commands: ["/rps", "/recently-played-search"], 278 | description: "Search recently played vidoes.", 279 | action: (_searchTerm, commandSegments) => { 280 | $.post({ 281 | url: "/api/videos/recently-played/search", 282 | data: { searchTerm: _.slice(commandSegments, 1).join(" ") } 283 | }).done((data: IVideoEntry[]) => { 284 | this.setVideoResultsState(data); 285 | }); 286 | } 287 | }, 288 | { 289 | commands: ["/trimrp", "trim-recently-played"], 290 | description: 291 | "Trim recently played videos in the database to the last 30.", 292 | action: (_searchTerm, _commandSegments) => { 293 | $.post({ 294 | url: "/api/videos/recently-played/last30", 295 | data: { trim: true } 296 | }).done((data: IVideoEntry[]) => { 297 | this.setVideoResultsState(data); 298 | }); 299 | } 300 | }, 301 | { 302 | commands: ["/manage"], 303 | description: 304 | "Switch mode to manage which allows you to edit groups videos belong to or delete them.", 305 | action: (_searchTerm, _commandSegments) => { 306 | $.ajax({ 307 | url: "/api/videos" 308 | }).done((data: IVideoEntry[]) => { 309 | this.setVideoResultsState(data, true); 310 | }); 311 | } 312 | }, 313 | { 314 | commands: ["/filter"], 315 | description: 316 | "Switch thumbnails and video to a user specified filter: monochrome, saturate, sepia and normal", 317 | action: (_searchTerm, commandSegments) => { 318 | if (commandSegments.length > 0) { 319 | switch (commandSegments[1]) { 320 | case "monochrome": 321 | this.setState({ applyFilter: "grayscale" }); 322 | break; 323 | case "saturate": 324 | this.setState({ applyFilter: "saturate" }); 325 | break; 326 | case "sepia": 327 | this.setState({ applyFilter: "sepia" }); 328 | break; 329 | case "normal": 330 | this.setState({ applyFilter: "normal" }); 331 | break; 332 | } 333 | } 334 | } 335 | } 336 | ]; 337 | } 338 | private onCommandEntered(searchTerm: string): void { 339 | const commandSegments: string[] = searchTerm.split(" "); 340 | 341 | const command: ICommand = _.find(this.commands, c => { 342 | return _.indexOf(c.commands, commandSegments[0]) > -1; 343 | }); 344 | 345 | if (command) { 346 | command.action(searchTerm, commandSegments); 347 | } else { 348 | this.performSearch(searchTerm, "/api/videos/youtube/search"); 349 | } 350 | } 351 | private onAddVideoButtonClick(video: IVideoEntry, group: string): void { 352 | let found = _.find(this.state.searchResults, { ytid: video.ytid }); 353 | 354 | if (found !== undefined) { 355 | found.isArchived = true; 356 | 357 | $.post({ 358 | url: "/api/videos/add", 359 | data: { 360 | title: video.title, 361 | ytid: video.ytid, 362 | group: group !== undefined ? group : "misc" 363 | } 364 | }); 365 | } 366 | } 367 | private onUpdateVideoButtonClick(video: IVideoEntry, group: string): void { 368 | let found = _.find(this.state.searchResults, { ytid: video.ytid }); 369 | 370 | if (found !== undefined) { 371 | found.isArchived = true; 372 | found.title = video.title; 373 | 374 | $.post({ 375 | url: "/api/videos/update", 376 | data: { 377 | title: video.title, 378 | ytid: video.ytid, 379 | group: group !== undefined ? group : "misc" 380 | } 381 | }); 382 | } 383 | } 384 | private onDeleteVideoButtonClick(video: IVideoEntry): void { 385 | const found = _.find(this.state.searchResults, { ytid: video.ytid }); 386 | 387 | if (found !== undefined) { 388 | $.post({ 389 | url: "/api/videos/delete", 390 | data: { 391 | ytid: video.ytid 392 | } 393 | }); 394 | 395 | this.setState({ 396 | searchResults: _.reject(this.state.searchResults, { ytid: video.ytid }) 397 | }); 398 | } 399 | } 400 | private playVideo(video: IVideoEntry): void { 401 | this.setState({ 402 | currentVideo: video, 403 | // searchResults: data, 404 | manage: false 405 | }); 406 | 407 | if (video.title !== undefined && video.title.length > 0) { 408 | $.post({ 409 | url: "/api/videos/recently-played/add", 410 | data: { 411 | title: video.title, 412 | ytid: video.ytid 413 | } 414 | }); 415 | } 416 | } 417 | render() { 418 | let versionDisplay = true, 419 | view; 420 | 421 | if ( 422 | this.state.searchResults !== undefined && 423 | this.state.searchResults.length > 0 424 | ) { 425 | versionDisplay = false; 426 | } 427 | 428 | if (this.state.gridView) { 429 | view = ( 430 | 434 | ); 435 | } else { 436 | view = ( 437 | 446 | ); 447 | } 448 | 449 | return ( 450 |
451 | 455 |
{view}
456 | 461 | 465 |
466 | ); 467 | } 468 | } 469 | 470 | $(document).ready(() => { 471 | ReactDOM.render(, document.getElementById("ui")); 472 | }); 473 | -------------------------------------------------------------------------------- /src/react-components/version-ui.tsx: -------------------------------------------------------------------------------- 1 | // version-ui.tsx - Version info React component for Toby 2 | // Author(s): Frank Hale 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU General Public License 15 | // along with this program. If not, see . 16 | 17 | import * as React from "react"; 18 | import * as _ from "lodash"; 19 | 20 | export interface IVersionProps { 21 | display: boolean; 22 | info: string; 23 | } 24 | 25 | interface IVersionState { 26 | display: boolean; 27 | info: string; 28 | } 29 | 30 | export class Version extends React.Component { 31 | constructor(props: IVersionProps) { 32 | super(props); 33 | 34 | this.state = { 35 | display: false, 36 | info: "" 37 | }; 38 | } 39 | 40 | static getDerivedStateFromProps( 41 | props: IVersionProps, 42 | state: IVersionState 43 | ): IVersionState { 44 | if (props.display !== undefined && !_.isEmpty(props.info)) { 45 | return { 46 | display: props.display, 47 | info: props.info 48 | }; 49 | } 50 | 51 | return null; 52 | } 53 | 54 | render() { 55 | if (this.state.display && !_.isEmpty(this.state.info)) { 56 | return
{this.state.info}
; 57 | } 58 | 59 | return null; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/react-components/video-list-grid-ui.tsx: -------------------------------------------------------------------------------- 1 | // video-list-grid-ui.tsx - A video list grid React component for Toby 2 | // Author(s): Frank Hale 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU General Public License 15 | // along with this program. If not, see . 16 | 17 | import * as React from "react"; 18 | import * as _ from "lodash"; 19 | 20 | import { ISearchResults } from "./infrastructure"; 21 | 22 | export interface IViewListGridProps { 23 | data: ISearchResults[]; 24 | applyFilter: string; 25 | } 26 | 27 | interface IViewListGridState { 28 | data?: ISearchResults[]; 29 | applyFilter?: string; 30 | } 31 | 32 | export class VideoListGrid extends React.Component< 33 | IViewListGridProps, 34 | IViewListGridState 35 | > { 36 | constructor(props: any) { 37 | super(props); 38 | 39 | this.state = { 40 | data: [], 41 | applyFilter: "" 42 | }; 43 | } 44 | 45 | static getDerivedStateFromProps( 46 | props: IViewListGridProps, 47 | state: IViewListGridState 48 | ): IViewListGridState { 49 | let videos: ISearchResults[] = []; 50 | 51 | if (!_.isEmpty(props.data)) { 52 | videos = props.data.map((d, i) => { 53 | return { 54 | playVideo: d.playVideo, 55 | title: d.title, 56 | ytid: d.ytid, 57 | group: d.group, 58 | thumbnail: d.thumbnail, 59 | isArchived: d.isArchived 60 | }; 61 | }); 62 | 63 | return { 64 | data: videos, 65 | applyFilter: props.applyFilter || "" 66 | }; 67 | } 68 | 69 | return null; 70 | } 71 | 72 | render() { 73 | return ( 74 |
75 | {this.state.data.map((d, i) => { 76 | return ( 77 | 84 | ); 85 | })} 86 |
87 | ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/react-components/video-list-ui.tsx: -------------------------------------------------------------------------------- 1 | // video-list-ui.tsx - A vertical video list React component for Toby 2 | // Author(s): Frank Hale 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU General Public License 15 | // along with this program. If not, see . 16 | 17 | import * as React from "react"; 18 | import * as $ from "jquery"; 19 | import * as _ from "lodash"; 20 | 21 | import { IDropDownItem, DropDown } from "./dropdown-ui"; 22 | import { IVideoEntry, ISearchResults } from "./infrastructure"; 23 | 24 | export interface IVideoListProps { 25 | data: ISearchResults[]; 26 | groups: string[]; 27 | applyFilter: string; 28 | onAddVideoButtonClick(video: IVideoEntry, group: string): void; 29 | onUpdateVideoButtonClick(video: IVideoEntry, group: string): void; 30 | onDeleteVideoButtonClick(video: IVideoEntry): void; 31 | manage?: boolean; 32 | } 33 | 34 | interface IVideoListState { 35 | items?: IDropDownItem[]; 36 | data?: ISearchResults[]; 37 | applyFilter?: string; 38 | onAddVideoButtonClick?: (video: IVideoEntry, group: string) => void; 39 | onUpdateVideoButtonClick?: (video: IVideoEntry, group: string) => void; 40 | onDeleteVideoButtonClick?: (video: IVideoEntry) => void; 41 | manage?: boolean; 42 | currentlySelectedGroup?: string; 43 | } 44 | 45 | export class VideoList extends React.Component< 46 | IVideoListProps, 47 | IVideoListState 48 | > { 49 | constructor(props: any) { 50 | super(props); 51 | 52 | this.onAddVideoButtonClick = this.onAddVideoButtonClick.bind(this); 53 | this.onUpdateVideoButtonClick = this.onUpdateVideoButtonClick.bind(this); 54 | this.onDeleteVideoButtonClick = this.onDeleteVideoButtonClick.bind(this); 55 | 56 | this.state = { 57 | data: [], 58 | applyFilter: "" 59 | }; 60 | } 61 | componentDidMount() { 62 | let $videoListTable = $("#videoListTable"); 63 | 64 | const resizeTable = () => { 65 | $videoListTable.css("width", window.innerWidth - 25); 66 | }; 67 | 68 | window.addEventListener("resize", e => { 69 | resizeTable(); 70 | }); 71 | 72 | resizeTable(); 73 | } 74 | 75 | static getDerivedStateFromProps( 76 | props: IVideoListProps, 77 | state: IVideoListState 78 | ): IVideoListState { 79 | let items: IDropDownItem[] = [ 80 | { 81 | name: "Select a Group", 82 | value: "-1", 83 | action: () => {} 84 | } 85 | ]; 86 | 87 | if (!_.isEmpty(props.groups)) { 88 | _.forEach(props.groups, g => { 89 | if (g !== "Recently Played") { 90 | items.push({ 91 | name: g, 92 | value: g, 93 | action: () => {} 94 | }); 95 | } 96 | }); 97 | } 98 | 99 | let videos: ISearchResults[] = []; 100 | 101 | if (props.data !== undefined) { 102 | videos = props.data.map(d => { 103 | return { 104 | playVideo: d.playVideo, 105 | title: d.title, 106 | ytid: d.ytid, 107 | group: d.group, 108 | thumbnail: d.thumbnail, 109 | isArchived: d.isArchived, 110 | justAdded: d.justAdded || false 111 | }; 112 | }); 113 | 114 | return { 115 | items: items, 116 | data: videos, 117 | applyFilter: props.applyFilter || "", 118 | onAddVideoButtonClick: props.onAddVideoButtonClick, 119 | onUpdateVideoButtonClick: props.onUpdateVideoButtonClick, 120 | onDeleteVideoButtonClick: props.onDeleteVideoButtonClick, 121 | manage: props.manage 122 | }; 123 | } 124 | 125 | return null; 126 | } 127 | 128 | private onAddVideoButtonClick(e: any): void { 129 | e.preventDefault(); 130 | 131 | let id = $(e.target) 132 | .prop("id") 133 | .replace("star-", ""), 134 | video = _.find(this.state.data, { ytid: id }) as ISearchResults, 135 | group = $(`#groupSelector-${video.ytid}`).val() as string; 136 | 137 | if (group === "-1") return; 138 | 139 | if (this.state.onAddVideoButtonClick !== undefined && video !== undefined) { 140 | this.state.onAddVideoButtonClick(video, group); 141 | 142 | let _d = _.forEach(this.state.data, d => { 143 | if (d.ytid === id) { 144 | d.justAdded = true; 145 | } 146 | }); 147 | 148 | this.setState({ data: _d, currentlySelectedGroup: group }); 149 | } 150 | } 151 | private onUpdateVideoButtonClick(e: any): void { 152 | e.preventDefault(); 153 | 154 | // console.log($(e.target).prop("id").replace("star-", "")); 155 | 156 | let id = $(e.target) 157 | .prop("id") 158 | .replace("star-", ""), 159 | video = _.find(this.state.data, { ytid: id }) as ISearchResults, 160 | group = $(`#groupSelector-${video.ytid}`).val() as string; 161 | 162 | if (group === "-1") return; 163 | 164 | if (!_.isEmpty(this.state.onUpdateVideoButtonClick) && !_.isEmpty(video)) { 165 | this.state.onUpdateVideoButtonClick(video, group); 166 | } 167 | } 168 | private onDeleteVideoButtonClick(e: any): void { 169 | e.preventDefault(); 170 | 171 | let id = $(e.target) 172 | .prop("id") 173 | .replace("star-", ""), 174 | video = _.find(this.state.data, { ytid: id }) as ISearchResults; 175 | 176 | if ( 177 | this.state.onDeleteVideoButtonClick !== undefined && 178 | video !== undefined 179 | ) { 180 | this.state.onDeleteVideoButtonClick(video); 181 | 182 | this.setState({ 183 | data: _.reject(this.state.data, { ytid: video.ytid }) 184 | }); 185 | } 186 | } 187 | render() { 188 | let videoResults = _.map(this.state.data, (d, i) => { 189 | let addButton, 190 | manageButton, 191 | addButtonColSpan = 2, 192 | borderRight, 193 | dropDownClass = "groupDropDown"; 194 | 195 | if (d.isArchived === false || d.justAdded === true) { 196 | let clickHandler = this.onAddVideoButtonClick, 197 | addButtonClass = "manageButton far fa-star"; 198 | 199 | if (d.justAdded) { 200 | clickHandler = (e: any) => { 201 | e.preventDefault(); 202 | e.stopPropagation(); 203 | }; 204 | dropDownClass = "groupDropDownDisabled"; 205 | addButtonClass = "manageButton fas fa-star"; 206 | addButton = ( 207 | 208 | 209 | 216 | 222 | 223 | 224 | ); 225 | } else { 226 | addButton = ( 227 | 228 | 229 | 234 | 240 | 241 | 242 | ); 243 | } 244 | } else if (this.state.manage) { 245 | let deleteButtonClass = "manageButton fas fa-trash", 246 | updateButtonClass = "manageButton fas fa-wrench"; 247 | 248 | manageButton = ( 249 | 250 | 251 | 257 | 263 | 269 | 270 | 271 | ); 272 | } else { 273 | borderRight = "border-right"; 274 | } 275 | 276 | return ( 277 | 278 | 283 | 287 | 288 | 294 | {d.title} 295 | 296 | {addButton} 297 | {manageButton} 298 | 299 | ); 300 | }); 301 | 302 | return ( 303 |
304 | 305 | {videoResults} 306 |
307 |
308 | ); 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /src/react-components/youtube-ui.tsx: -------------------------------------------------------------------------------- 1 | // youtube-ui.tsx - YouTube player React component for Toby 2 | // Author(s): Frank Hale 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU General Public License 15 | // along with this program. If not, see . 16 | 17 | import * as React from "react"; 18 | import * as $ from "jquery"; 19 | 20 | import { IVideoEntry } from "./infrastructure"; 21 | 22 | interface Window { 23 | onYouTubeIframeAPIReady(): void; 24 | snapToPlayer(): void; 25 | addKeyHandlers(): void; 26 | } 27 | declare var window: Window; 28 | 29 | interface IYouTubeState { 30 | player?: any; 31 | applyFilter?: string; 32 | currentVideo?: IVideoEntry; 33 | } 34 | 35 | export interface IYouTubeProps { 36 | socket: SocketIOClient.Socket; 37 | video: IVideoEntry; 38 | applyFilter: string; 39 | } 40 | 41 | if ( 42 | navigator.userAgent.indexOf("node-webkit") > -1 || 43 | navigator.userAgent.indexOf("Electron") > -1 44 | ) { 45 | // This is here because when exiting fullscreen in NW.js the page scrolls to 46 | // top instead of centering on the YouTube player. This is called by an 47 | // injected script into the webview that Toby lives inside of when running in 48 | // NW.js. 49 | window.snapToPlayer = () => { 50 | let $ui = $("#ui"); 51 | $("#ui").prop("scrollTop", $ui.prop("scrollHeight")); 52 | }; 53 | } 54 | 55 | export class YouTube extends React.Component { 56 | constructor(props: any) { 57 | super(props); 58 | 59 | this.state = { 60 | currentVideo: {} as IVideoEntry 61 | }; 62 | } 63 | componentDidMount(): void { 64 | $.getScript( 65 | "https://www.youtube.com/iframe_api", 66 | (_data, textStatus, _jqxhr) => { 67 | if (textStatus === "success") { 68 | console.log("YouTube API loaded..."); 69 | this.setupYTPlayer(); 70 | } 71 | } 72 | ); 73 | } 74 | 75 | static getDerivedStateFromProps( 76 | props: IYouTubeProps, 77 | state: IYouTubeState 78 | ): IYouTubeState { 79 | if ( 80 | navigator.userAgent.indexOf("node-webkit") > -1 || 81 | navigator.userAgent.indexOf("Electron") > -1 82 | ) { 83 | if ( 84 | props.applyFilter !== undefined && 85 | props.applyFilter.length > 0 && 86 | state.applyFilter !== props.applyFilter 87 | ) { 88 | let $player = $("#player") 89 | .contents() 90 | .find(".html5-main-video"); 91 | 92 | switch (props.applyFilter) { 93 | case "grayscale": 94 | $player.css("-webkit-filter", "grayscale(1)"); 95 | break; 96 | case "saturate": 97 | $player.css("-webkit-filter", "saturate(2.5)"); 98 | break; 99 | case "sepia": 100 | $player.css("-webkit-filter", "sepia(1)"); 101 | break; 102 | case "normal": 103 | $player.css("-webkit-filter", ""); 104 | break; 105 | } 106 | 107 | return { 108 | applyFilter: props.applyFilter 109 | }; 110 | } 111 | } 112 | 113 | return null; 114 | } 115 | 116 | getSnapshotBeforeUpdate(_prevProps: IYouTubeProps, prevState: IYouTubeState) { 117 | if (this.props.video === undefined) return null; 118 | 119 | if (prevState.currentVideo.ytid !== this.props.video.ytid) { 120 | return { 121 | video: this.props.video 122 | }; 123 | } 124 | 125 | return null; 126 | } 127 | 128 | componentDidUpdate( 129 | _prevProps: IYouTubeProps, 130 | _prevState: IYouTubeState, 131 | snapshot: any 132 | ) { 133 | if (snapshot !== null) { 134 | this.setState( 135 | { 136 | currentVideo: snapshot.video 137 | }, 138 | () => { 139 | if ( 140 | this.state.currentVideo.title === "" && 141 | this.state.currentVideo.ytid === "" 142 | ) { 143 | this.state.player.stopVideo(); 144 | $("#player").css("display", "none"); 145 | } else { 146 | this.playVideo(this.state.currentVideo); 147 | } 148 | } 149 | ); 150 | } 151 | } 152 | 153 | private setupYTPlayer(): void { 154 | let player: YT.Player = undefined; 155 | 156 | const onPlayerReady = () => { 157 | player.setVolume(30); 158 | }; 159 | 160 | const onPlayerStateChange = (e: any) => { 161 | const videoInfo = e.target.getVideoData(); 162 | 163 | if ( 164 | (videoInfo.title !== "" && this.state.currentVideo.title === "") || 165 | this.state.currentVideo.title !== videoInfo.title 166 | ) { 167 | document.title = videoInfo.title; 168 | 169 | if (this.props.socket !== undefined) { 170 | this.props.socket.emit("title", videoInfo.title); 171 | } 172 | 173 | this.setState({ 174 | currentVideo: { 175 | title: videoInfo.title, 176 | ytid: videoInfo.video_id 177 | } 178 | }); 179 | 180 | $.post({ 181 | url: "/api/videos/recently-played/add", 182 | data: { 183 | title: videoInfo.title, 184 | ytid: videoInfo.video_id 185 | } 186 | }); 187 | } 188 | }; 189 | 190 | window.onYouTubeIframeAPIReady = () => { 191 | player = new YT.Player("player", { 192 | videoId: "", 193 | playerVars: { 194 | autoplay: 1, 195 | iv_load_policy: 3 196 | }, 197 | events: { 198 | onReady: onPlayerReady, 199 | onStateChange: onPlayerStateChange 200 | } 201 | }); 202 | 203 | let $player = $("#player"); 204 | 205 | if (navigator.userAgent.indexOf("node-webkit") > -1) { 206 | $player.attr("nwdisable", ""); 207 | } 208 | 209 | if ( 210 | navigator.userAgent.indexOf("node-webkit") > -1 || 211 | navigator.userAgent.indexOf("Electron") > -1 212 | ) { 213 | setInterval(() => { 214 | $player 215 | .contents() 216 | .find(".video-ads") 217 | .css("display", "none"); 218 | }, 1000); 219 | } 220 | 221 | this.setState({ player: player }); 222 | }; 223 | } 224 | private playVideo(video: IVideoEntry): void { 225 | if (!this.state.player) return; 226 | 227 | this.state.player.setVolume(30); 228 | this.state.player.loadVideoById(video.ytid); 229 | 230 | const $player = $("#player"), 231 | $ui = $("#ui"); 232 | 233 | if ($player.css("display") !== "block") { 234 | $player.css("display", "block"); 235 | } 236 | 237 | $ui.animate({ scrollTop: $ui.prop("scrollHeight") }, 250); 238 | } 239 | render() { 240 | return
; 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /src/searchCache.ts: -------------------------------------------------------------------------------- 1 | // searchCache.ts - A simple search cache for Toby which caches YouTube search 2 | // results. 3 | // Author(s): Frank Hale 4 | // 5 | // This program is free software: you can redistribute it and/or modify 6 | // it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or 8 | // (at your option) any later version. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | import * as _ from "lodash"; 19 | import * as Moment from "moment"; 20 | import { IVideoEntry } from "./infrastructure"; 21 | 22 | export interface ICacheItem { 23 | searchTerm: string; 24 | results: IVideoEntry[]; 25 | addedAt: Moment.Moment; 26 | } 27 | 28 | export class SearchCache { 29 | private cache: ICacheItem[]; 30 | private expiry: number; 31 | 32 | constructor(expiry?: number) { 33 | this.cache = []; 34 | 35 | if (expiry) { 36 | this.expiry = expiry; 37 | } else { 38 | this.expiry = 10; 39 | } 40 | 41 | setInterval(() => { 42 | this.runExpire(); 43 | }, this.expiry * 60000); 44 | } 45 | private runExpire(): void { 46 | if (this.cache.length <= 0) return; 47 | 48 | this.cache = _.reject(this.cache, c => { 49 | return c.addedAt < Moment().subtract(this.expiry, "minutes"); 50 | }); 51 | } 52 | addItem(searchTerm: string, results: IVideoEntry[]) { 53 | let found = _.find(this.cache, { searchTerm: searchTerm }); 54 | 55 | if (!found) { 56 | this.cache.push({ 57 | searchTerm: searchTerm, 58 | results: results, 59 | addedAt: Moment() 60 | }); 61 | } 62 | } 63 | inCache(searchTerm: string): boolean { 64 | const result = _.find(this.cache, { searchTerm: searchTerm }); 65 | 66 | if (result) return true; 67 | 68 | return false; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | // server.ts - Express setup and initiation 2 | // Author(s): Frank Hale 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU General Public License 15 | // along with this program. If not, see . 16 | 17 | import * as bodyParser from "body-parser"; 18 | import * as cookieParser from "cookie-parser"; 19 | import * as express from "express"; 20 | import * as favicon from "serve-favicon"; 21 | import * as logger from "morgan"; 22 | import * as path from "path"; 23 | import * as http from "http"; 24 | import * as debug from "debug"; 25 | 26 | import AppConfig from "./config"; 27 | import API from "./api"; 28 | import DB from "./db"; 29 | 30 | const pkgJSON = require("../package.json"); 31 | 32 | export default class Server { 33 | private app: express.Application; 34 | 35 | constructor() { 36 | this.app = express(); 37 | this.config(); 38 | } 39 | static bootstrap() { 40 | return new Server(); 41 | } 42 | config(): void { 43 | let server: http.Server, 44 | db: DB, 45 | api: API, 46 | serverPort = AppConfig.serverPort; 47 | 48 | debug("toby:server"); 49 | 50 | this.app.use(logger("dev")); 51 | this.app.use(bodyParser.json()); 52 | this.app.use(bodyParser.urlencoded({ extended: false })); 53 | this.app.use(cookieParser()); 54 | this.app.set("view engine", "hbs"); 55 | this.app.set("views", path.join(__dirname, "../views")); 56 | this.app.use( 57 | favicon(path.join(__dirname, "../public", "images", "toby.ico")) 58 | ); 59 | this.app.use(express.static(path.join(__dirname, "../public"))); 60 | this.app.set("port", serverPort); 61 | 62 | server = http.createServer(this.app); 63 | server.listen(serverPort); 64 | server.on("error", (error: NodeJS.ErrnoException) => { 65 | if (error.syscall !== "listen") { 66 | throw error; 67 | } 68 | 69 | const bind = 70 | typeof serverPort === "string" 71 | ? `Pipe ${serverPort}` 72 | : `Port ${serverPort}`; 73 | 74 | // handle specific listen errors with friendly messages 75 | switch (error.code) { 76 | case "EACCES": 77 | console.error(bind + " requires elevated privileges"); 78 | process.exit(1); 79 | break; 80 | case "EADDRINUSE": 81 | console.error(bind + " is already in use"); 82 | process.exit(1); 83 | break; 84 | default: 85 | throw error; 86 | } 87 | }); 88 | server.on("listening", () => { 89 | const addr = server.address(); 90 | const bind = 91 | typeof addr === "string" ? `pipe ${addr}` : `port ${addr.port}`; 92 | debug(`Listening on ${bind}`); 93 | console.log(`Listening on ${bind}`); 94 | }); 95 | 96 | db = new DB(); 97 | api = new API(db, server); 98 | 99 | this.app.get("/", (_req, res, _next) => { 100 | res.render("index", { title: pkgJSON.title }); 101 | }); 102 | 103 | this.app.use("/api", api.router); 104 | 105 | // catch 404 and forward to error handler 106 | this.app.use((req, _res, next) => { 107 | let err = new Error("Not Found"); 108 | err["status"] = 404; 109 | 110 | console.log(req.path); 111 | next(err); 112 | }); 113 | 114 | // development error handler 115 | // will print stacktrace 116 | if (this.app.get("env") === "development") { 117 | this.app.use( 118 | ( 119 | err: Error, 120 | _req: express.Request, 121 | res: express.Response, 122 | _next: express.NextFunction 123 | ) => { 124 | res.status(err["status"] || 500); 125 | 126 | console.log(err.stack); 127 | 128 | res.render("error", { 129 | message: err.message, 130 | error: err 131 | }); 132 | } 133 | ); 134 | } 135 | 136 | // production error handler 137 | // no stacktraces leaked to user 138 | this.app.use( 139 | ( 140 | err: Error, 141 | _req: express.Request, 142 | res: express.Response, 143 | _next: express.NextFunction 144 | ) => { 145 | res.status(err["status"] || 500); 146 | res.render("error", { 147 | message: err.message, 148 | error: {} 149 | }); 150 | } 151 | ); 152 | } 153 | } 154 | 155 | Server.bootstrap(); 156 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./public/scripts/", 4 | "sourceMap": true, 5 | "noImplicitAny": true, 6 | "module": "commonjs", 7 | "target": "es6", 8 | "jsx": "react" 9 | }, 10 | "include": ["./src/react-components/*.ts*"] 11 | } 12 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "jsRules": { 3 | "class-name": true, 4 | "comment-format": [ 5 | true, 6 | "check-space" 7 | ], 8 | "indent": [ 9 | true, 10 | "spaces" 11 | ], 12 | "no-duplicate-variable": true, 13 | "no-eval": true, 14 | "no-trailing-whitespace": true, 15 | "no-unsafe-finally": true, 16 | "one-line": [ 17 | true, 18 | "check-open-brace", 19 | "check-whitespace" 20 | ], 21 | "quotemark": [ 22 | true, 23 | "double" 24 | ], 25 | "semicolon": [ 26 | true, 27 | "always" 28 | ], 29 | "triple-equals": [ 30 | true, 31 | "allow-null-check" 32 | ], 33 | "variable-name": [ 34 | true, 35 | "ban-keywords" 36 | ], 37 | "whitespace": [ 38 | true, 39 | "check-branch", 40 | "check-decl", 41 | "check-operator", 42 | "check-separator", 43 | "check-type" 44 | ] 45 | }, 46 | "rules": { 47 | "class-name": true, 48 | "comment-format": [ 49 | true, 50 | "check-space" 51 | ], 52 | "indent": [ 53 | true, 54 | "spaces" 55 | ], 56 | "no-eval": true, 57 | "no-internal-module": true, 58 | "no-trailing-whitespace": true, 59 | "no-unsafe-finally": true, 60 | "no-var-keyword": true, 61 | "one-line": [ 62 | true, 63 | "check-open-brace", 64 | "check-whitespace" 65 | ], 66 | "quotemark": [ 67 | true, 68 | "double" 69 | ], 70 | "semicolon": [ 71 | true, 72 | "always" 73 | ], 74 | "triple-equals": [ 75 | true, 76 | "allow-null-check" 77 | ], 78 | "typedef-whitespace": [ 79 | true, { 80 | "call-signature": "nospace", 81 | "index-signature": "nospace", 82 | "parameter": "nospace", 83 | "property-declaration": "nospace", 84 | "variable-declaration": "nospace" 85 | } 86 | ], 87 | "variable-name": [ 88 | true, 89 | "ban-keywords" 90 | ], 91 | "whitespace": [ 92 | true, 93 | "check-branch", 94 | "check-decl", 95 | "check-operator", 96 | "check-separator", 97 | "check-type" 98 | ] 99 | } 100 | } -------------------------------------------------------------------------------- /views/error.hbs: -------------------------------------------------------------------------------- 1 |

{{message}} - {{error.status}}

2 | {{error.stack}} 3 | -------------------------------------------------------------------------------- /views/index.hbs: -------------------------------------------------------------------------------- 1 |
2 | 3 | -------------------------------------------------------------------------------- /views/layout.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{{title}}} 6 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {{{body}}} 18 | 19 | 20 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | //const UglifyJsPlugin = require("uglifyjs-webpack-plugin"); 3 | 4 | module.exports = { 5 | mode: "development", 6 | entry: [ 7 | "command-input-ui.tsx", 8 | "dropdown-ui.tsx", 9 | "infrastructure.ts", 10 | "toby-ui.tsx", 11 | "server-log-ui.tsx", 12 | "version-ui.tsx", 13 | "video-list-grid-ui.tsx", 14 | "video-list-ui.tsx", 15 | "youtube-ui.tsx" 16 | ], 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.tsx?$/, 21 | use: "ts-loader", 22 | exclude: /node_modules/ 23 | } 24 | ] 25 | }, 26 | resolve: { 27 | extensions: [".tsx", ".ts", ".js"], 28 | modules: [path.resolve("./src/react-components"), "node_modules"] 29 | }, 30 | //plugins: [new UglifyJsPlugin()], 31 | output: { 32 | filename: "app.js", 33 | path: __dirname + "/public/scripts" 34 | } 35 | // optimization: { 36 | // splitChunks: { 37 | // chunks: "all" 38 | // } 39 | // } 40 | }; 41 | --------------------------------------------------------------------------------