├── public ├── favicon.ico ├── downloads │ └── .gitkeep ├── robots.txt ├── airflix-demo.gif ├── touch-icon-ipad.png ├── touch-icon-iphone.png ├── touch-icon-ipad-retina.png ├── touch-icon-iphone-retina.png ├── mix-manifest.json ├── .htaccess ├── web.config ├── js │ └── manifest.d41d8cd98f00b204e980.js └── index.php ├── database ├── seeds │ ├── .gitkeep │ └── DatabaseSeeder.php ├── migrations │ ├── .gitkeep │ ├── 2016_07_18_184112_create_failed_jobs_table.php │ ├── 2016_02_19_221248_create_genres_table.php │ └── 2016_02_20_151342_create_movies_table.php └── .gitignore ├── storage ├── .gitignore ├── app │ ├── images │ │ ├── .gitignore │ │ ├── backdrops │ │ │ └── .gitignore │ │ ├── episodes │ │ │ └── .gitignore │ │ ├── posters │ │ │ └── .gitignore │ │ ├── seasons │ │ │ └── .gitignore │ │ ├── no-poster.png │ │ └── no-backdrop.png │ └── public │ │ └── .gitignore ├── logs │ └── .gitignore ├── tmdb │ └── .gitignore └── framework │ ├── cache │ └── .gitignore │ ├── views │ └── .gitignore │ ├── sessions │ └── .gitignore │ └── .gitignore ├── resources ├── views │ ├── vendor │ │ └── .gitkeep │ ├── errors │ │ ├── 503.blade.php │ │ └── 404.blade.php │ └── app.blade.php ├── assets │ ├── sass │ │ ├── _typography.scss │ │ ├── _fonts.scss │ │ ├── _transitions.scss │ │ ├── _overview.scss │ │ ├── _summaries.scss │ │ ├── _tags.scss │ │ ├── app.scss │ │ ├── _header.scss │ │ ├── _search.scss │ │ ├── _grids.scss │ │ ├── _scaffold.scss │ │ ├── _cards.scss │ │ ├── _carousel.scss │ │ ├── _buttons.scss │ │ ├── _spinner.scss │ │ ├── _navigation.scss │ │ └── _toasts.scss │ └── js │ │ ├── vuex │ │ ├── modules │ │ │ ├── toasts.js │ │ │ ├── genres.js │ │ │ ├── images.js │ │ │ ├── search.js │ │ │ ├── episodes.js │ │ │ ├── views.js │ │ │ ├── interfaces.js │ │ │ └── seasons.js │ │ ├── helpers.js │ │ ├── store.js │ │ └── mutation-types.js │ │ ├── components │ │ ├── screens │ │ │ └── NotFoundScreen.vue │ │ ├── statuses │ │ │ └── Spinner.vue │ │ ├── partials │ │ │ ├── ShowCard.vue │ │ │ ├── MovieCard.vue │ │ │ ├── ShowResult.vue │ │ │ ├── MovieResult.vue │ │ │ ├── ShowPoster.vue │ │ │ ├── MoviePoster.vue │ │ │ ├── ShowBackdrop.vue │ │ │ ├── MovieBackdrop.vue │ │ │ └── Episode.vue │ │ └── charts │ │ │ ├── MonthlyChart.vue │ │ │ └── LineChart.vue │ │ ├── filters.js │ │ ├── routes.js │ │ └── app.js └── lang │ └── en │ ├── pagination.php │ ├── auth.php │ └── passwords.php ├── bootstrap ├── cache │ └── .gitignore ├── autoload.php └── app.php ├── .gitattributes ├── .babelrc ├── Airflix ├── Contracts │ ├── MovieResults.php │ ├── ShowResults.php │ ├── TmdbImageClient.php │ ├── TmdbImageTransformer.php │ ├── MovieViewMonthlyTransformer.php │ ├── SeasonViewMonthlyTransformer.php │ ├── ShowViewMonthlyTransformer.php │ ├── TmdbMovieResultTransformer.php │ ├── TmdbShowResultTransformer.php │ ├── EpisodeViewMonthlyTransformer.php │ ├── ShowImages.php │ ├── MovieImages.php │ ├── GenreTransformer.php │ ├── MovieViews.php │ ├── SeasonTransformer.php │ ├── EpisodeViews.php │ ├── Seasons.php │ ├── EpisodeTransformer.php │ ├── Episodes.php │ ├── Genres.php │ ├── Settings.php │ ├── MovieTransformer.php │ ├── ShowTransformer.php │ ├── Shows.php │ ├── Movies.php │ └── ApiResponse.php ├── Uuid.php ├── MovieView.php ├── Filterable.php ├── EpisodeView.php ├── User.php ├── Retriable.php ├── V1 │ ├── ShowViewMonthlyTransformer.php │ ├── MovieViewMonthlyTransformer.php │ ├── SeasonViewMonthlyTransformer.php │ ├── EpisodeViewMonthlyTransformer.php │ ├── TmdbShowResultTransformer.php │ ├── TmdbMovieResultTransformer.php │ ├── TmdbImageTransformer.php │ ├── GenreTransformer.php │ ├── SeasonTransformer.php │ └── EpisodeTransformer.php ├── Genre.php ├── GenreFilters.php ├── TmdbImageClient.php ├── MovieFilters.php ├── ShowFilters.php ├── ShowResults.php ├── MovieResults.php ├── ShowImages.php ├── MovieImages.php ├── MovieViews.php └── Episodes.php ├── tests ├── TestCase.php ├── Feature │ ├── AppPageTest.php │ ├── MovieDownloadTest.php │ ├── EpisodeDownloadTest.php │ ├── GenresApiTest.php │ ├── SeasonsApiTest.php │ └── EpisodesApiTest.php ├── CreatesApplication.php └── Unit │ └── RetriableTest.php ├── .gitignore ├── .travis.yml ├── app ├── Http │ ├── Middleware │ │ ├── EncryptCookies.php │ │ ├── VerifyCsrfToken.php │ │ ├── TrimStrings.php │ │ └── RedirectIfAuthenticated.php │ ├── Controllers │ │ ├── HomeController.php │ │ ├── Controller.php │ │ ├── ImageDownloadController.php │ │ ├── Api │ │ │ ├── Settings │ │ │ │ ├── FoldersController.php │ │ │ │ ├── HistoryController.php │ │ │ │ └── SettingsController.php │ │ │ ├── GenreController.php │ │ │ ├── SeasonController.php │ │ │ ├── ApiController.php │ │ │ ├── EpisodeController.php │ │ │ ├── ShowPosterController.php │ │ │ ├── MoviePosterController.php │ │ │ ├── ShowBackdropController.php │ │ │ ├── MovieBackdropController.php │ │ │ ├── ShowResultController.php │ │ │ └── MovieResultController.php │ │ ├── MovieDownloadController.php │ │ └── EpisodeDownloadController.php │ └── Kernel.php ├── Providers │ ├── BroadcastServiceProvider.php │ ├── AuthServiceProvider.php │ ├── EventServiceProvider.php │ └── RouteServiceProvider.php ├── Jobs │ ├── Job.php │ ├── ClearHistory.php │ ├── ClearHistoryToday.php │ ├── RefreshAll.php │ └── RefreshNew.php ├── Console │ ├── Commands │ │ ├── RefreshGenres.php │ │ ├── ClearHistory.php │ │ ├── RefreshShows.php │ │ ├── RefreshMovies.php │ │ └── SetAPIKeys.php │ └── Kernel.php └── Exceptions │ └── Handler.php ├── routes ├── channels.php ├── console.php └── web.php ├── server.php ├── .env.example ├── webpack.mix.js ├── config ├── tmdb.php ├── services.php ├── view.php ├── airflix.php ├── broadcasting.php ├── cache.php ├── queue.php └── filesystems.php ├── LICENSE.txt ├── phpunit.xml ├── package.json ├── artisan └── composer.json /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /database/seeds/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /database/migrations/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/downloads/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /storage/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite -------------------------------------------------------------------------------- /database/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite 2 | -------------------------------------------------------------------------------- /resources/views/vendor/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /storage/app/images/.gitignore: -------------------------------------------------------------------------------- 1 | /.cache 2 | -------------------------------------------------------------------------------- /bootstrap/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/tmdb/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /storage/app/public/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /storage/framework/views/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/app/images/backdrops/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/app/images/episodes/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/app/images/posters/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/app/images/seasons/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/sessions/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.css linguist-vendored 3 | *.less linguist-vendored 4 | -------------------------------------------------------------------------------- /public/airflix-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wells/airflix/HEAD/public/airflix-demo.gif -------------------------------------------------------------------------------- /public/touch-icon-ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wells/airflix/HEAD/public/touch-icon-ipad.png -------------------------------------------------------------------------------- /public/touch-icon-iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wells/airflix/HEAD/public/touch-icon-iphone.png -------------------------------------------------------------------------------- /storage/app/images/no-poster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wells/airflix/HEAD/storage/app/images/no-poster.png -------------------------------------------------------------------------------- /public/touch-icon-ipad-retina.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wells/airflix/HEAD/public/touch-icon-ipad-retina.png -------------------------------------------------------------------------------- /storage/app/images/no-backdrop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wells/airflix/HEAD/storage/app/images/no-backdrop.png -------------------------------------------------------------------------------- /public/touch-icon-iphone-retina.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wells/airflix/HEAD/public/touch-icon-iphone-retina.png -------------------------------------------------------------------------------- /storage/framework/.gitignore: -------------------------------------------------------------------------------- 1 | config.php 2 | routes.php 3 | compiled.php 4 | services.json 5 | events.scanned.php 6 | routes.scanned.php 7 | down 8 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["es2015", { "modules": false }], 4 | ], 5 | "plugins": [ 6 | "transform-object-rest-spread" 7 | ] 8 | } -------------------------------------------------------------------------------- /Airflix/Contracts/MovieResults.php: -------------------------------------------------------------------------------- 1 | 2 |
3 |

4 | Page Not Found 5 |

6 |
7 | 8 | 9 | -------------------------------------------------------------------------------- /resources/assets/sass/_transitions.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Transitions 3 | // -------------------------------------------------- 4 | 5 | .loading-enter-active, .loading-leave-active { 6 | transition: all $animation-fast ease; 7 | } 8 | .loading-enter, .loading-leave-active { 9 | opacity: 0; 10 | } -------------------------------------------------------------------------------- /resources/assets/sass/_overview.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Overview 3 | // -------------------------------------------------- 4 | 5 | .overview { 6 | overflow: auto; 7 | 8 | img { 9 | width: 150px; 10 | float: left; 11 | margin-right: $padding / 2; 12 | margin-bottom: $padding / 2; 13 | } 14 | } -------------------------------------------------------------------------------- /Airflix/Contracts/MovieViews.php: -------------------------------------------------------------------------------- 1 | call(UserTableSeeder::class); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /resources/assets/js/components/statuses/Spinner.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 5.6 5 | - 7.0 6 | 7 | env: 8 | APP_ENV: testing 9 | APP_KEY: Tgm8GqW1Ldao5Q8maftoViYeKOWgrFE1 10 | CACHE_DRIVER: array 11 | SESSION_DRIVER: array 12 | QUEUE_DRIVER: sync 13 | DB_CONNECTION: sqlite 14 | 15 | install: 16 | - travis_retry composer install --no-interaction --prefer-source 17 | 18 | script: 19 | - vendor/bin/phpunit; -------------------------------------------------------------------------------- /Airflix/Contracts/MovieTransformer.php: -------------------------------------------------------------------------------- 1 | attributes['uuid'] = (string) \Ramsey\Uuid\Uuid::uuid4(); 17 | 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/Http/Middleware/EncryptCookies.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | Options -MultiViews 4 | 5 | 6 | RewriteEngine On 7 | 8 | # Redirect Trailing Slashes If Not A Folder... 9 | RewriteCond %{REQUEST_FILENAME} !-d 10 | RewriteRule ^(.*)/$ /$1 [L,R=301] 11 | 12 | # Handle Front Controller... 13 | RewriteCond %{REQUEST_FILENAME} !-d 14 | RewriteCond %{REQUEST_FILENAME} !-f 15 | RewriteRule ^ index.php [L] 16 | 17 | -------------------------------------------------------------------------------- /Airflix/Contracts/Movies.php: -------------------------------------------------------------------------------- 1 | apply($query); 20 | } 21 | } -------------------------------------------------------------------------------- /Airflix/EpisodeView.php: -------------------------------------------------------------------------------- 1 | get('/'); 16 | 17 | $response->assertStatus(200); 18 | $response->assertSee('Airflix'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /routes/channels.php: -------------------------------------------------------------------------------- 1 | id === (int) $id; 16 | }); -------------------------------------------------------------------------------- /resources/assets/js/vuex/modules/search.js: -------------------------------------------------------------------------------- 1 | import { addRecords } from '../helpers' 2 | import * as types from '../mutation-types' 3 | 4 | export default { 5 | state: { 6 | movies: [], 7 | shows: [], 8 | }, 9 | mutations: { 10 | [types.CLEAR_ALL] (state) { 11 | state.movies = [] 12 | state.shows = [] 13 | }, 14 | [types.ADD_MOVIE_RESULTS] (state, results) { 15 | addRecords(state.movies, results, 'movie-results') 16 | }, 17 | [types.ADD_SHOW_RESULTS] (state, results) { 18 | addRecords(state.shows, results, 'show-results') 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /resources/assets/js/components/partials/ShowCard.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /tests/CreatesApplication.php: -------------------------------------------------------------------------------- 1 | make(Kernel::class)->bootstrap(); 20 | 21 | // Speed up encryption for tests 22 | Hash::setRounds(5); 23 | 24 | return $app; 25 | } 26 | } -------------------------------------------------------------------------------- /resources/assets/js/components/partials/MovieCard.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /app/Jobs/Job.php: -------------------------------------------------------------------------------- 1 | comment(Inspiring::quote()); 18 | })->describe('Display an inspiring quote'); 19 | -------------------------------------------------------------------------------- /bootstrap/autoload.php: -------------------------------------------------------------------------------- 1 | '« Previous', 17 | 'next' => 'Next »', 18 | 19 | ]; 20 | -------------------------------------------------------------------------------- /app/Http/Controllers/ImageDownloadController.php: -------------------------------------------------------------------------------- 1 | getImageResponse($path, $request->all()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /resources/assets/js/vuex/modules/episodes.js: -------------------------------------------------------------------------------- 1 | import { addRecord, addRecords } from '../helpers' 2 | import * as types from '../mutation-types' 3 | 4 | export default { 5 | state: { 6 | currentID: null, 7 | all: [], 8 | }, 9 | mutations: { 10 | [types.CLEAR_ALL] (state) { 11 | state.all = [] 12 | }, 13 | [types.SELECT_EPISODE] (state, id) { 14 | state.currentID = id 15 | }, 16 | [types.ADD_EPISODE] (state, episode) { 17 | addRecord(state.all, episode, 'episodes') 18 | }, 19 | [types.ADD_EPISODES] (state, episodes) { 20 | addRecords(state.all, episodes, 'episodes') 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Airflix/User.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | 10 | $uri = urldecode( 11 | parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) 12 | ); 13 | 14 | // This file allows us to emulate Apache's "mod_rewrite" functionality from the 15 | // built-in PHP web server. This provides a convenient way to test a Laravel 16 | // application without having installed a "real" web server software here. 17 | if ($uri !== '/' && file_exists(__DIR__.'/public'.$uri)) { 18 | return false; 19 | } 20 | 21 | require_once __DIR__.'/public/index.php'; 22 | -------------------------------------------------------------------------------- /app/Http/Middleware/RedirectIfAuthenticated.php: -------------------------------------------------------------------------------- 1 | check()) { 21 | return redirect('/'); 22 | } 23 | 24 | return $next($request); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_ENV=local 2 | APP_DEBUG=true 3 | APP_KEY=SomeRandomStringThatIsLongEnough 4 | APP_TIMEZONE="UTC" 5 | APP_URL="http://airflix.local" 6 | 7 | AIRFLIX_EXTENSIONS_VIDEO="m4v, mp4" 8 | TMDB_API_KEY=ApplyForAnApiKey 9 | 10 | DB_HOST=localhost 11 | DB_DATABASE=airflix 12 | DB_USERNAME=homestead 13 | DB_PASSWORD=secret 14 | 15 | CACHE_DRIVER=redis 16 | SESSION_DRIVER=redis 17 | QUEUE_DRIVER=redis 18 | 19 | REDIS_HOST=localhost 20 | REDIS_PASSWORD=null 21 | REDIS_PORT=6379 22 | 23 | MAIL_DRIVER=smtp 24 | MAIL_HOST=mailtrap.io 25 | MAIL_PORT=2525 26 | MAIL_USERNAME=null 27 | MAIL_PASSWORD=null 28 | MAIL_ENCRYPTION=null 29 | 30 | PUSHER_APP_ID= 31 | PUSHER_APP_KEY= 32 | PUSHER_APP_SECRET= 33 | -------------------------------------------------------------------------------- /resources/assets/sass/_tags.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Tags 3 | // -------------------------------------------------- 4 | 5 | .tags { 6 | list-style-type: none; 7 | padding: 0px; 8 | 9 | li { 10 | display: inline-block; 11 | background-color: rgba(white, 0.05); 12 | color: $color-font-light; 13 | border-radius: 4px; 14 | padding: 8px 10px; 15 | margin-right: 5px; 16 | margin-bottom: 10px; 17 | } 18 | 19 | .key { 20 | color: $color-font-verylight; 21 | float: left; 22 | display: block; 23 | margin-right: 12px; 24 | } 25 | 26 | .value { 27 | position: relative; 28 | overflow: auto; 29 | } 30 | } 31 | 32 | .tags.mini { 33 | font-size: 0.85em; 34 | } -------------------------------------------------------------------------------- /resources/assets/sass/app.scss: -------------------------------------------------------------------------------- 1 | /*! 2 | * Airflix 3 | * Copyright Brian Wells 4 | * Licensed under MIT (https://github.com/wells/airflix/blob/master/LICENSE.txt) 5 | */ 6 | 7 | // Normalize.css 8 | @import "node_modules/normalize.css/normalize"; 9 | 10 | // Core variables and mixins 11 | @import "fonts"; 12 | @import "variables"; 13 | 14 | @import "scaffold"; 15 | @import "typography"; 16 | @import "transitions"; 17 | @import "header"; 18 | @import "navigation"; 19 | @import "grids"; 20 | @import "cards"; 21 | @import "forms"; 22 | @import "buttons"; 23 | @import "carousel"; 24 | @import "overview"; 25 | @import "search"; 26 | @import "spinner"; 27 | @import "summaries"; 28 | @import "tags"; 29 | @import "toasts"; -------------------------------------------------------------------------------- /resources/lang/en/auth.php: -------------------------------------------------------------------------------- 1 | 'These credentials do not match our records.', 17 | 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 18 | 19 | ]; 20 | -------------------------------------------------------------------------------- /app/Providers/AuthServiceProvider.php: -------------------------------------------------------------------------------- 1 | 'App\Policies\ModelPolicy', 17 | ]; 18 | 19 | /** 20 | * Register any authentication / authorization services. 21 | * 22 | * @return void 23 | */ 24 | public function boot() 25 | { 26 | $this->registerPolicies(); 27 | 28 | // 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/Jobs/ClearHistory.php: -------------------------------------------------------------------------------- 1 | [ 17 | 'App\Listeners\EventListener', 18 | ], 19 | ]; 20 | 21 | /** 22 | * Register any events for your application. 23 | * 24 | * @return void 25 | */ 26 | public function boot() 27 | { 28 | parent::boot(); 29 | 30 | // 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /resources/assets/js/vuex/modules/views.js: -------------------------------------------------------------------------------- 1 | import { addRecords } from '../helpers' 2 | import * as types from '../mutation-types' 3 | 4 | export default { 5 | state: { 6 | movies: [], 7 | seasons: [], 8 | shows: [], 9 | }, 10 | mutations: { 11 | [types.CLEAR_ALL] (state) { 12 | state.movies = [] 13 | state.seasons = [] 14 | state.shows = [] 15 | }, 16 | 17 | [types.ADD_MOVIE_VIEWS] (state, views) { 18 | addRecords(state.movies, views, 'movie-views') 19 | }, 20 | 21 | [types.ADD_SEASON_VIEWS] (state, views) { 22 | addRecords(state.seasons, views, 'season-views') 23 | }, 24 | 25 | [types.ADD_SHOW_VIEWS] (state, views) { 26 | addRecords(state.shows, views, 'show-views') 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /resources/assets/sass/_header.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Header 3 | // -------------------------------------------------- 4 | 5 | #header { 6 | width: 100%; 7 | position: relative; 8 | top: 0; 9 | left: 0; 10 | padding: $padding / 2 $padding; 11 | overflow: auto; 12 | transition: left $animation-fast ease; 13 | 14 | #logo { 15 | text-transform: uppercase; 16 | font-weight: 800; 17 | font-size: 24px; 18 | color: $color-accent-green; 19 | letter-spacing: -1px; 20 | padding: 10px 15px; 21 | float: left; 22 | line-height: 1em; 23 | } 24 | } 25 | 26 | @media only screen and (min-width: 768px) 27 | { 28 | #header { 29 | left: 200px; 30 | width: calc(100% - 200px); 31 | 32 | #logo { 33 | padding: 10px 0px !important; 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /app/Jobs/ClearHistoryToday.php: -------------------------------------------------------------------------------- 1 | true 34 | ]); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Feature/MovieDownloadTest.php: -------------------------------------------------------------------------------- 1 | movie = factory(Movie::class)->create(); 22 | } 23 | 24 | /** @test */ 25 | public function it_fails_to_download_a_movie() 26 | { 27 | $response = $this->get('/downloads/movies/'.$this->movie->uuid); 28 | 29 | $response->assertStatus(404); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/Jobs/RefreshAll.php: -------------------------------------------------------------------------------- 1 | episode = factory(Episode::class)->create(); 22 | } 23 | 24 | /** @test */ 25 | public function it_fails_to_download_a_episode() 26 | { 27 | $response = $this->get('/downloads/episodes/'.$this->episode->uuid); 28 | 29 | $response->assertStatus(404); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Airflix/Contracts/ApiResponse.php: -------------------------------------------------------------------------------- 1 | $view->get('id'), 27 | 'show_id' => $view->get('show_uuid'), 28 | 'total' => $view->get('total'), 29 | 'label' => $view->get('label'), 30 | ]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Airflix/V1/MovieViewMonthlyTransformer.php: -------------------------------------------------------------------------------- 1 | $view->get('id'), 27 | 'movie_id' => $view->get('movie_uuid'), 28 | 'total' => $view->get('total'), 29 | 'label' => $view->get('label'), 30 | ]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Airflix/V1/SeasonViewMonthlyTransformer.php: -------------------------------------------------------------------------------- 1 | $view->get('id'), 27 | 'season_id' => $view->get('season_uuid'), 28 | 'total' => $view->get('total'), 29 | 'label' => $view->get('label'), 30 | ]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Airflix/V1/EpisodeViewMonthlyTransformer.php: -------------------------------------------------------------------------------- 1 | $view->get('id'), 27 | 'episode_id' => $view->get('episode_uuid'), 28 | 'total' => $view->get('total'), 29 | 'label' => $view->get('label'), 30 | ]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /database/migrations/2016_07_18_184112_create_failed_jobs_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 17 | $table->text('connection'); 18 | $table->text('queue'); 19 | $table->longText('payload'); 20 | $table->timestamp('failed_at')->useCurrent(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::drop('failed_jobs'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /resources/lang/en/passwords.php: -------------------------------------------------------------------------------- 1 | 'Passwords must be at least six characters and match the confirmation.', 17 | 'reset' => 'Your password has been reset!', 18 | 'sent' => 'We have e-mailed your password reset link!', 19 | 'token' => 'This password reset token is invalid.', 20 | 'user' => "We can't find a user with that e-mail address.", 21 | 22 | ]; 23 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/Settings/FoldersController.php: -------------------------------------------------------------------------------- 1 | dispatch(new RefreshAll()); 24 | } else { 25 | $this->dispatch(new RefreshNew()); 26 | } 27 | 28 | return $this->apiResponse() 29 | ->respondWithArray([]); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /resources/assets/js/components/charts/MonthlyChart.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/assets/sass/_search.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Search 3 | // -------------------------------------------------- 4 | 5 | div.search { 6 | height: 0px; 7 | overflow-y: hidden; 8 | opacity: 0; 9 | transition: opacity, height $animation-fast ease; 10 | 11 | .fields { 12 | float: left; 13 | width: calc(100% - 88px); 14 | } 15 | .clear { 16 | float: left; 17 | } 18 | .button { 19 | padding: 34px 20px; 20 | margin: 0px 0px 0px 8px; 21 | } 22 | .genres, .order { 23 | float: left; 24 | width: calc(100% * 1 / 2 - 4px); 25 | } 26 | .order { 27 | margin-right: 8px; 28 | } 29 | } 30 | 31 | @media only screen and (max-width: 767px) 32 | { 33 | .toggle-search div.search { 34 | opacity: 1.0; 35 | height: 100px; 36 | } 37 | } 38 | 39 | @media only screen and (min-width: 768px) 40 | { 41 | div.search { 42 | opacity: 1.0; 43 | height: 100px; 44 | } 45 | } -------------------------------------------------------------------------------- /app/Http/Controllers/Api/Settings/HistoryController.php: -------------------------------------------------------------------------------- 1 | dispatch(new ClearHistory()); 24 | } else { 25 | $this->dispatch(new ClearHistoryToday()); 26 | } 27 | 28 | return $this->apiResponse() 29 | ->respondWithArray([]); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/Jobs/RefreshNew.php: -------------------------------------------------------------------------------- 1 | true 35 | ]); 36 | Artisan::call('airflix:shows', [ 37 | '--new' => true 38 | ]); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /resources/assets/js/vuex/helpers.js: -------------------------------------------------------------------------------- 1 | export function addRecords (records, newRecords, type) { 2 | if (!newRecords) { 3 | return 4 | } 5 | 6 | newRecords.forEach(newRecord => { 7 | addRecord(records, newRecord, type) 8 | }) 9 | } 10 | 11 | export function addRecord (records, newRecord, type, callback) { 12 | if(!newRecord || newRecord.type != type) { 13 | return 14 | } 15 | 16 | // Find record index (if any) 17 | let index = records.findIndex(r => r.id == newRecord.id) 18 | 19 | // Add record 20 | if (index == -1) { 21 | records.push(newRecord) 22 | return 23 | } 24 | 25 | // Update record with callback 26 | if (callback) { 27 | records[index].attributes = newRecord.attributes 28 | records[index].links = newRecord.links 29 | 30 | callback(records[index], newRecord) 31 | return 32 | } 33 | 34 | // Replace record 35 | records.splice(index, 1, newRecord) 36 | } 37 | -------------------------------------------------------------------------------- /resources/assets/js/vuex/store.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import episodes from './modules/episodes' 4 | import filters from './modules/filters' 5 | import genres from './modules/genres' 6 | import images from './modules/images' 7 | import interfaces from './modules/interfaces' 8 | import movies from './modules/movies' 9 | import search from './modules/search' 10 | import seasons from './modules/seasons' 11 | import settings from './modules/settings' 12 | import shows from './modules/shows' 13 | import toasts from './modules/toasts' 14 | import views from './modules/views' 15 | 16 | Vue.use(Vuex) 17 | 18 | export default new Vuex.Store({ 19 | modules: { 20 | episodes, 21 | filters, 22 | genres, 23 | images, 24 | interfaces, 25 | movies, 26 | search, 27 | seasons, 28 | settings, 29 | shows, 30 | toasts, 31 | views 32 | }, 33 | strict: process.env.NODE_ENV !== 'production' 34 | }) 35 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | const { mix } = require('laravel-mix'); 2 | 3 | /* 4 | |-------------------------------------------------------------------------- 5 | | Mix Asset Management 6 | |-------------------------------------------------------------------------- 7 | | 8 | | Mix provides a clean, fluent API for defining some Webpack build steps 9 | | for your Laravel application. By default, we are compiling the Sass 10 | | file for the application as well as bundling up all the JS files. 11 | | 12 | */ 13 | 14 | mix.js('resources/assets/js/app.js', 'public/js') 15 | .extract([ 16 | 'axios', 17 | 'chart.js', 18 | 'lodash', 19 | 'moment', 20 | 'moment-range', 21 | 'vue', 22 | 'vue-moment', 23 | 'vue-router', 24 | 'vuex', 25 | 'vuex-router-sync' 26 | ]) 27 | .sass('resources/assets/sass/app.scss', 'public/css'); 28 | 29 | if (mix.config.inProduction) { 30 | mix.version(); 31 | } -------------------------------------------------------------------------------- /tests/Feature/GenresApiTest.php: -------------------------------------------------------------------------------- 1 | genre = factory(Genre::class)->create(); 22 | } 23 | 24 | /** @test */ 25 | public function it_fetches_genres() 26 | { 27 | $response = $this->json('GET', '/api/genres'); 28 | 29 | $response->assertStatus(200); 30 | $response->assertJsonFragment([ 31 | 'id' => $this->genre->uuid, 32 | ]); 33 | $response->assertJsonStructure([ 34 | 'data', 35 | 'meta', 36 | ]); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /config/tmdb.php: -------------------------------------------------------------------------------- 1 | 5 | * @copyright (c) 2014, Mark Redeman 6 | */ 7 | return [ 8 | /* 9 | * Api key 10 | */ 11 | 'api_key' => env('TMDB_API_KEY', ''), 12 | 13 | /** 14 | * Client options 15 | */ 16 | 'options' => [ 17 | /** 18 | * Use https 19 | */ 20 | 'secure' => true, 21 | 22 | /* 23 | * Cache 24 | */ 25 | 'cache' => [ 26 | 'enabled' => true, 27 | // Keep the path empty or remove it entirely to default to storage/tmdb 28 | 'path' => storage_path('tmdb') 29 | ], 30 | 31 | /* 32 | * Log 33 | */ 34 | 'log' => [ 35 | 'enabled' => false, 36 | // Keep the path empty or remove it entirely to default to storage/logs/tmdb.log 37 | 'path' => storage_path('logs/tmdb.log') 38 | ] 39 | ], 40 | ]; 41 | -------------------------------------------------------------------------------- /public/web.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Airflix/Genre.php: -------------------------------------------------------------------------------- 1 | belongsToMany( 29 | Movie::class, 'genre_movie', 'genre_id', 'movie_id' 30 | ); 31 | } 32 | 33 | /** 34 | * Get the tv shows for the genre. 35 | * 36 | * @return \Illuminate\Database\Eloquent\Relations\Relation 37 | */ 38 | public function shows() 39 | { 40 | return $this->belongsToMany( 41 | Show::class, 'genre_show', 'genre_id', 'show_id' 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /database/migrations/2016_02_19_221248_create_genres_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 17 | $table->uuid('uuid')->unique(); 18 | $table->integer('tmdb_genre_id')->index()->unsigned()->unique(); 19 | $table->string('name'); 20 | $table->integer('total_movies')->unsigned()->default(0); 21 | $table->integer('total_shows')->unsigned()->default(0); 22 | $table->timestamps(); 23 | }); 24 | } 25 | 26 | /** 27 | * Reverse the migrations. 28 | * 29 | * @return void 30 | */ 31 | public function down() 32 | { 33 | Schema::dropIfExists('genres'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Airflix/V1/TmdbShowResultTransformer.php: -------------------------------------------------------------------------------- 1 | $result['id'], 27 | 'name' => $result['name'], 28 | 'original_name' => $result['original_name'], 29 | 'poster_path' => $result['poster_path'], 30 | 'poster_url' => $result['poster_path'] ? 31 | config('airflix.tmdb.previews').$result['poster_path'] : 32 | asset('images/no-poster.png'), 33 | 'first_air_date' => $result['first_air_date'], 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Airflix/V1/TmdbMovieResultTransformer.php: -------------------------------------------------------------------------------- 1 | $result['id'], 27 | 'title' => $result['title'], 28 | 'original_title' => $result['original_title'], 29 | 'poster_path' => $result['poster_path'], 30 | 'poster_url' => $result['poster_path'] ? 31 | config('airflix.tmdb.previews').$result['poster_path'] : 32 | asset('images/no-poster.png'), 33 | 'release_date' => $result['release_date'], 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Airflix/GenreFilters.php: -------------------------------------------------------------------------------- 1 | query; 39 | } 40 | 41 | return $this->query 42 | ->where(function ($query) use ($keywords) { 43 | $query->where('name', 'like', 44 | '%'.$keywords.'%'); 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Brian Wells 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /app/Http/Controllers/Api/GenreController.php: -------------------------------------------------------------------------------- 1 | genres() 34 | ->index($relationships, $pagination); 35 | 36 | $transformer = $this->genres() 37 | ->transformer(); 38 | 39 | return $this->apiResponse() 40 | ->respondWithCollection( 41 | $genres, 42 | $transformer 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | 'download.images', 17 | 'uses' => 'ImageDownloadController@show' 18 | ])->where('path', '.+'); 19 | 20 | // Episode Downloads 21 | Route::get('downloads/episodes/{id}', [ 22 | 'as' => 'download.episodes', 23 | 'uses' => 'EpisodeDownloadController@show' 24 | ]); 25 | 26 | // Movie Downloads 27 | Route::get('downloads/movies/{id}', [ 28 | 'as' => 'download.movies', 29 | 'uses' => 'MovieDownloadController@show' 30 | ]); 31 | 32 | // Load the SPA from any route that does not start with __ 33 | Route::get('{path?}', [ 34 | 'as' => 'home', 35 | 'uses' => 'HomeController@index' 36 | ])->where('path', '^(?!__).+'); 37 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'domain' => env('MAILGUN_DOMAIN'), 19 | 'secret' => env('MAILGUN_SECRET'), 20 | ], 21 | 22 | 'mandrill' => [ 23 | 'secret' => env('MANDRILL_SECRET'), 24 | ], 25 | 26 | 'ses' => [ 27 | 'key' => env('SES_KEY'), 28 | 'secret' => env('SES_SECRET'), 29 | 'region' => 'us-east-1', 30 | ], 31 | 32 | 'stripe' => [ 33 | 'model' => App\User::class, 34 | 'key' => env('STRIPE_KEY'), 35 | 'secret' => env('STRIPE_SECRET'), 36 | ], 37 | 38 | ]; 39 | -------------------------------------------------------------------------------- /config/view.php: -------------------------------------------------------------------------------- 1 | [ 17 | realpath(base_path('resources/views')), 18 | ], 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Compiled View Path 23 | |-------------------------------------------------------------------------- 24 | | 25 | | This option determines where all the compiled Blade templates will be 26 | | stored for your application. Typically, this is within the storage 27 | | directory. However, as usual, you are free to change this value. 28 | | 29 | */ 30 | 31 | 'compiled' => realpath(storage_path('framework/views')), 32 | 33 | ]; 34 | -------------------------------------------------------------------------------- /resources/assets/js/vuex/modules/interfaces.js: -------------------------------------------------------------------------------- 1 | import * as types from '../mutation-types' 2 | 3 | export default { 4 | state: { 5 | showMenu: false, 6 | showSearch: false, 7 | loadingRouteData: true, 8 | }, 9 | mutations: { 10 | [types.LOADING_ROUTE] (state) { 11 | state.loadingRouteData = true 12 | }, 13 | [types.LOADED_ROUTE] (state) { 14 | state.loadingRouteData = false 15 | }, 16 | [types.HIDE_MENU] (state) { 17 | state.showMenu = false 18 | }, 19 | [types.TOGGLE_MENU] (state) { 20 | state.showMenu = ! state.showMenu 21 | }, 22 | [types.TOGGLE_SEARCH] (state) { 23 | state.showSearch = ! state.showSearch 24 | } 25 | }, 26 | actions: { 27 | loadingRoute (context) { 28 | context.commit(types.LOADING_ROUTE) 29 | }, 30 | loadedRoute (context) { 31 | context.commit(types.LOADED_ROUTE) 32 | }, 33 | hideMenu (context) { 34 | context.commit(types.HIDE_MENU) 35 | }, 36 | toggleMenu (context) { 37 | context.commit(types.TOGGLE_MENU) 38 | }, 39 | toggleSearch (context) { 40 | context.commit(types.TOGGLE_SEARCH) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Airflix/V1/TmdbImageTransformer.php: -------------------------------------------------------------------------------- 1 | trim($image['file_path'], '/'), 27 | 'file_path' => $image['file_path'], 28 | 'file_url' => config('airflix.tmdb.previews'). 29 | $image['file_path'], 30 | 'iso_639_1' => $image['iso_639_1'], 31 | 'aspect_ratio' => (double) $image['aspect_ratio'], 32 | 'width' => (int) $image['width'], 33 | 'height' => (int) $image['height'], 34 | 'vote_average' => (double) $image['vote_average'], 35 | 'vote_count' => (int) $image['vote_count'], 36 | ]; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/Http/Controllers/MovieDownloadController.php: -------------------------------------------------------------------------------- 1 | movies() 40 | ->get($id); 41 | 42 | if (! $movie->has_file) { 43 | return abort(404); 44 | } 45 | 46 | $this->views() 47 | ->watch($movie); 48 | 49 | return redirect($movie->file_path); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /resources/assets/js/components/partials/ShowResult.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | -------------------------------------------------------------------------------- /resources/assets/js/components/partials/MovieResult.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | -------------------------------------------------------------------------------- /config/airflix.php: -------------------------------------------------------------------------------- 1 | [ 6 | 'url' => env('APP_URL').'/api', 7 | 'versions' => [ 8 | 1.0, 9 | ], 10 | ], 11 | 12 | 'extensions' => [ 13 | 'video' => array_map('trim', 14 | array_filter( 15 | explode(',', env('AIRFLIX_EXTENSIONS_VIDEO', 'm4v, mp4')), 16 | 'strlen' 17 | ) 18 | ), 19 | ], 20 | 21 | 'per_page' => 100, 22 | 23 | 'tmdb' => [ 24 | 'images' => 'https://image.tmdb.org/t/p/original', 25 | 'previews' => 'https://image.tmdb.org/t/p/w300', 26 | 'languages' => 'en,null', // maximum of 5 27 | 'per_page' => 20, 28 | 'size' => [ 29 | 'backdrops' => 1080, 30 | 'posters' => 480, 31 | 'seasons' => 320, 32 | 'episodes' => 480, 33 | ], 34 | 'throttle_seconds' => 10, 35 | ], 36 | 37 | 'urls' => [ 38 | 'imdb' => 'http://www.imdb.com/title/', 39 | 'tmdb' => [ 40 | 'movie' => 'https://www.themoviedb.org/movie/', 41 | 'show' => 'https://www.themoviedb.org/tv/', 42 | ], 43 | 'tvdb' => 'http://thetvdb.com/?tab=series&id=', 44 | ], 45 | 46 | ]; 47 | -------------------------------------------------------------------------------- /resources/assets/js/components/partials/ShowPoster.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /resources/assets/js/components/partials/MoviePoster.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /resources/assets/js/routes.js: -------------------------------------------------------------------------------- 1 | // Import screens 2 | import MoviesScreen from './components/screens/MoviesScreen.vue' 3 | import MovieScreen from './components/screens/MovieScreen.vue' 4 | import MovieEditScreen from './components/screens/MovieEditScreen.vue' 5 | import SettingsScreen from './components/screens/SettingsScreen.vue' 6 | import ShowsScreen from './components/screens/ShowsScreen.vue' 7 | import ShowScreen from './components/screens/ShowScreen.vue' 8 | import ShowEditScreen from './components/screens/ShowEditScreen.vue' 9 | import SeasonScreen from './components/screens/SeasonScreen.vue' 10 | import NotFoundScreen from './components/screens/NotFoundScreen.vue' 11 | 12 | export const routes = [ 13 | { path: '/', redirect: '/movies' }, 14 | { path: '/movies', component: MoviesScreen }, 15 | { path: '/movies/:id', component: MovieScreen }, 16 | { path: '/movies/:id/edit', component: MovieEditScreen }, 17 | { path: '/settings', component: SettingsScreen }, 18 | { path: '/shows', component: ShowsScreen }, 19 | { path: '/shows/:id', component: ShowScreen }, 20 | { path: '/shows/:id/edit', component: ShowEditScreen }, 21 | { path: '/shows/seasons/:id', component: SeasonScreen }, 22 | // Not Found Handler 23 | { path: '*', component: NotFoundScreen } 24 | ] 25 | -------------------------------------------------------------------------------- /resources/assets/js/components/partials/ShowBackdrop.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /resources/assets/js/components/partials/MovieBackdrop.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/SeasonController.php: -------------------------------------------------------------------------------- 1 | seasons() 38 | ->get($id, $relationships); 39 | 40 | $transformer = $this->seasons() 41 | ->transformer(); 42 | 43 | $this->apiResponse() 44 | ->fractal() 45 | ->parseIncludes($relationships); 46 | 47 | return $this->apiResponse() 48 | ->respondWithItem( 49 | $season, 50 | $transformer 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/ApiController.php: -------------------------------------------------------------------------------- 1 | episodes() 42 | ->get($id, $relationships); 43 | 44 | if (! $episode->has_file) { 45 | return abort(404); 46 | } 47 | 48 | $this->views() 49 | ->watch($episode); 50 | 51 | return redirect($episode->file_path); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Airflix/TmdbImageClient.php: -------------------------------------------------------------------------------- 1 | httpClient = new Client([ 23 | 'timeout' => 2.0, 24 | ]); 25 | } 26 | 27 | /** 28 | * Download an image from the tmdb API. 29 | * 30 | * @param string $fileName 31 | * @param string $folder 32 | * 33 | * @return bool 34 | */ 35 | public function download($fileName, $folder) 36 | { 37 | $filePath = 'images/'.$folder.$fileName; 38 | 39 | $storage = Storage::disk('app'); 40 | 41 | // The image was already downloaded 42 | if (!$fileName || $storage->exists($filePath)) { 43 | return false; 44 | } 45 | 46 | $url = config('airflix.tmdb.images').$fileName; 47 | 48 | $response = $this->httpClient 49 | ->get($url); 50 | 51 | $storage->put($filePath, $response->getBody()); 52 | 53 | return true; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./tests/Feature 14 | 15 | 16 | 17 | ./tests/Unit 18 | 19 | 20 | 21 | 22 | ./app 23 | ./Airflix 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/EpisodeController.php: -------------------------------------------------------------------------------- 1 | episodes() 39 | ->get($id, $relationships); 40 | 41 | $transformer = $this->episodes() 42 | ->transformer(); 43 | 44 | $this->apiResponse() 45 | ->fractal() 46 | ->parseIncludes($relationships); 47 | 48 | return $this->apiResponse() 49 | ->respondWithItem( 50 | $episode, 51 | $transformer 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "node node_modules/cross-env/bin/cross-env.js NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 5 | "watch": "node node_modules/cross-env/bin/cross-env.js NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 6 | "hot": "node node_modules/cross-env/bin/cross-env.js NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", 7 | "production": "node node_modules/cross-env/bin/cross-env.js NODE_ENV=production node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js" 8 | }, 9 | "devDependencies": { 10 | "axios": "^0.15.3", 11 | "babel-plugin-transform-object-rest-spread": "^6.22.0", 12 | "chart.js": "^2.4.0", 13 | "laravel-mix": "^0.7.2", 14 | "lodash": "^4.17.4", 15 | "moment": "^2.17.1", 16 | "moment-range": "^2.2.0", 17 | "normalize.css": "^4.2.0", 18 | "vue": "^2.1.6", 19 | "vue-moment": "^1.0.8", 20 | "vue-mugen-scroll": "^0.2.1", 21 | "vue-router": "^2.1.1", 22 | "vuex": "^2.1.1", 23 | "vuex-router-sync": "^3.0.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /public/js/manifest.d41d8cd98f00b204e980.js: -------------------------------------------------------------------------------- 1 | !function(e){function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}var r=window.webpackJsonp;window.webpackJsonp=function(t,a,u){for(var i,c,f,l=0,s=[];lshows() 42 | ->get($id); 43 | 44 | $posters = $this->showImages() 45 | ->getPosters($show); 46 | 47 | $transformer = $this->showImages() 48 | ->transformer(); 49 | 50 | return $this->apiResponse() 51 | ->respondWithCollection( 52 | $posters, 53 | $transformer 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /resources/assets/sass/_grids.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Grids 3 | // -------------------------------------------------- 4 | 5 | .grid { 6 | list-style-type: none; 7 | padding: 0px; 8 | overflow: auto; 9 | 10 | li { 11 | margin-bottom: $padding / 3; 12 | } 13 | 14 | li:nth-child(1n) { 15 | float: left; 16 | margin-right: $padding / 3; 17 | } 18 | 19 | .button { 20 | width: 100%; 21 | margin-right: 0px; 22 | margin-bottom: 0px; 23 | } 24 | } 25 | 26 | @media only screen and (max-width: 767px) 27 | { 28 | .grid { 29 | li { 30 | width: calc(100% * 1 / 2 - 5px); 31 | } 32 | 33 | li:nth-child(2n+1) { 34 | clear: left; 35 | } 36 | 37 | li:nth-child(2n) { 38 | margin-right: 0px !important; 39 | } 40 | } 41 | } 42 | 43 | @media only screen and (min-width: 768px) and (max-width: 1023px) 44 | { 45 | .grid { 46 | li { 47 | width: calc(100% * 1 / 3 - 6.667px); 48 | } 49 | 50 | li:nth-child(3n+1) { 51 | clear: left; 52 | } 53 | 54 | li:nth-child(3n) { 55 | margin-right: 0px !important; 56 | } 57 | } 58 | } 59 | 60 | @media only screen and (min-width: 1024px) 61 | { 62 | .grid { 63 | li { 64 | width: calc(100% * 1 / 4 - 7.5px); 65 | } 66 | 67 | li:nth-child(4n+1) { 68 | clear: left; 69 | } 70 | 71 | li:nth-child(4n) { 72 | margin-right: 0px !important; 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /resources/assets/js/app.js: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | Airflix 4 | |-------------------------------------------------------------------------- 5 | */ 6 | 7 | import Vue from 'vue' 8 | import _ from 'lodash' 9 | import VueRouter from 'vue-router' 10 | import VueMoment from 'vue-moment' 11 | import axios from 'axios' 12 | import store from './vuex/store' 13 | import { zeroPad } from './filters' 14 | import { routes } from './routes' 15 | import { sync } from 'vuex-router-sync' 16 | import App from './components/App.vue' 17 | 18 | // Devtools enabled 19 | Vue.config.devtools = false 20 | 21 | // Silence logs and warnings 22 | Vue.config.silent = true 23 | 24 | // install lodash 25 | window._ = _ 26 | 27 | // install axios 28 | window.axios = axios 29 | 30 | axios.defaults.headers.common['Accept'] = 31 | 'application/vnd.api+json; version=1; charset=utf-8' 32 | 33 | // install router 34 | Vue.use(VueRouter) 35 | 36 | // install vue-moment filter 37 | Vue.use(VueMoment) 38 | 39 | // register filters globally 40 | Vue.filter('zeroPad', zeroPad) 41 | 42 | // create router 43 | const router = new VueRouter({ 44 | mode: 'history', 45 | routes 46 | }) 47 | 48 | // synchronize vue-router routes with vuex 49 | sync(store, router) 50 | 51 | window.vueRouter = router 52 | 53 | const app = new Vue({ 54 | router, 55 | store, 56 | ...App 57 | }).$mount('#app') 58 | -------------------------------------------------------------------------------- /app/Console/Commands/RefreshGenres.php: -------------------------------------------------------------------------------- 1 | genres() 42 | ->refreshGenres($this->output); 43 | 44 | $this->line( 45 | 'Refreshed: '. 46 | $totalGenres.' genres '. 47 | 'loaded from themoviedb.org' 48 | ); 49 | } 50 | 51 | /** 52 | * Inject the genres resource. 53 | * 54 | * @return \Airflix\Contracts\Genres 55 | */ 56 | protected function genres() { 57 | return app(Genres::class); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/MoviePosterController.php: -------------------------------------------------------------------------------- 1 | movies() 42 | ->get($id); 43 | 44 | $posters = $this->movieImages() 45 | ->getPosters($movie); 46 | 47 | $transformer = $this->movieImages() 48 | ->transformer(); 49 | 50 | return $this->apiResponse() 51 | ->respondWithCollection( 52 | $posters, 53 | $transformer 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/Console/Kernel.php: -------------------------------------------------------------------------------- 1 | command('tmdb:genres') 34 | ->weekly(); 35 | $schedule->command('tmdb:movies') 36 | ->weekly(); 37 | $schedule->command('tmdb:shows') 38 | ->weekly(); 39 | } 40 | 41 | /** 42 | * Register the Closure based commands for the application. 43 | * 44 | * @return void 45 | */ 46 | protected function commands() 47 | { 48 | require base_path('routes/console.php'); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/ShowBackdropController.php: -------------------------------------------------------------------------------- 1 | shows() 42 | ->get($id); 43 | 44 | $backdrops = $this->showImages() 45 | ->getBackdrops($show); 46 | 47 | $transformer = $this->showImages() 48 | ->transformer(); 49 | 50 | return $this->apiResponse() 51 | ->respondWithCollection( 52 | $backdrops, 53 | $transformer 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /resources/assets/js/components/charts/LineChart.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/MovieBackdropController.php: -------------------------------------------------------------------------------- 1 | movies() 42 | ->get($id); 43 | 44 | $backdrops = $this->movieImages() 45 | ->getBackdrops($movie); 46 | 47 | $transformer = $this->movieImages() 48 | ->transformer(); 49 | 50 | return $this->apiResponse() 51 | ->respondWithCollection( 52 | $backdrops, 53 | $transformer 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /resources/assets/js/components/partials/Episode.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | -------------------------------------------------------------------------------- /tests/Feature/SeasonsApiTest.php: -------------------------------------------------------------------------------- 1 | season = factory(Season::class)->create(); 22 | } 23 | 24 | /** @test */ 25 | public function it_fetches_a_single_season() 26 | { 27 | $response = $this->json('GET', '/api/seasons/'.$this->season->uuid); 28 | 29 | $response->assertStatus(200); 30 | $response->assertJsonFragment([ 31 | 'name' => $this->season->name, 32 | ]); 33 | $response->assertJsonStructure([ 34 | 'data' => [ 35 | 'relationships' => [ 36 | 'episodes', 37 | 'show', 38 | 'views', 39 | ], 40 | ], 41 | 'included', 42 | 'meta', 43 | ]); 44 | } 45 | 46 | /** @test */ 47 | public function it_404s_if_a_season_is_not_found() 48 | { 49 | $response = $this->json('GET', '/api/seasons/x'); 50 | 51 | $response->assertStatus(404); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/Feature/EpisodesApiTest.php: -------------------------------------------------------------------------------- 1 | episode = factory(Episode::class)->create(); 22 | } 23 | 24 | /** @test */ 25 | public function it_fetches_a_single_episode() 26 | { 27 | $response = $this->json('GET', '/api/episodes/'.$this->episode->uuid); 28 | 29 | $response->assertStatus(200); 30 | $response->assertJsonFragment([ 31 | 'id' => $this->episode->uuid, 32 | ]); 33 | $response->assertJsonStructure([ 34 | 'data' => [ 35 | 'attributes', 36 | 'relationships' => [ 37 | 'show', 38 | 'season', 39 | 'views', 40 | ], 41 | ], 42 | 'included', 43 | 'meta', 44 | ]); 45 | } 46 | 47 | /** @test */ 48 | public function it_404s_if_a_episode_is_not_found() 49 | { 50 | $response = $this->json('GET', '/api/episodes/x'); 51 | 52 | $response->assertStatus(404); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /resources/assets/sass/_scaffold.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Scaffold 3 | // -------------------------------------------------- 4 | 5 | html { 6 | min-height: 100%; 7 | box-sizing: border-box; 8 | } 9 | 10 | *, *:before, *:after { 11 | box-sizing: inherit; 12 | } 13 | 14 | body { 15 | background: $bg-primary; 16 | min-height: 100%; 17 | margin: 0; 18 | padding: 0; 19 | color: $color-font; 20 | font-family: $font; 21 | line-height: 1.6em; 22 | } 23 | 24 | #app { 25 | overflow-x: hidden; 26 | } 27 | 28 | a { 29 | color: $color-font; 30 | text-decoration: none; 31 | } 32 | 33 | a { 34 | &.disabled, 35 | fieldset[disabled] & { 36 | cursor: $cursor-disabled; 37 | pointer-events: none; // Future-proof disabling of clicks on `` elements 38 | } 39 | } 40 | 41 | img { 42 | width: 100%; 43 | border-radius: $border-radius-base; 44 | } 45 | 46 | #app { 47 | display: flex; 48 | min-height: 100vh; 49 | flex-direction: column; 50 | justify-content: space-around; 51 | } 52 | 53 | #content { 54 | flex: 1; 55 | overflow: auto; 56 | } 57 | 58 | .movies, 59 | .shows, 60 | .movie, 61 | .movie-edit, 62 | .not-found, 63 | .season, 64 | .settings, 65 | .show, 66 | .show-edit { 67 | padding: 0px $padding $padding; 68 | overflow: auto; 69 | } 70 | 71 | 72 | .results, 73 | .search { 74 | overflow: auto; 75 | } 76 | 77 | #footer { 78 | color: $color-accent-green; 79 | background-color: rgba(white, 0.1); 80 | text-align: center; 81 | font-weight: 300; 82 | padding: $padding / 2; 83 | } -------------------------------------------------------------------------------- /resources/assets/js/vuex/modules/seasons.js: -------------------------------------------------------------------------------- 1 | import { addRecord, addRecords } from '../helpers' 2 | import * as types from '../mutation-types' 3 | 4 | export default { 5 | state: { 6 | currentID: null, 7 | all: [], 8 | }, 9 | mutations: { 10 | [types.CLEAR_ALL] (state) { 11 | state.all = [] 12 | }, 13 | 14 | [types.SELECT_SEASON] (state, id) { 15 | state.currentID = id 16 | }, 17 | 18 | [types.ADD_SEASON] (state, season) { 19 | addRecord(state.all, season, 'seasons') 20 | }, 21 | 22 | [types.ADD_SEASONS] (state, seasons) { 23 | addRecords(state.all, seasons, 'seasons') 24 | } 25 | }, 26 | actions: { 27 | getSeason (context, payload) { 28 | context.commit(types.SELECT_SEASON, payload.id) 29 | 30 | // GET /api/seasons/{id} 31 | axios.get(payload.url) 32 | .then(function (response) { 33 | // success callback 34 | let season = response.data 35 | context.commit(types.ADD_SEASON, season.data) 36 | context.commit(types.ADD_EPISODES, season.included) 37 | context.commit(types.ADD_SHOWS, season.included) 38 | context.commit(types.ADD_GENRES, season.included) 39 | context.commit(types.ADD_SEASON_VIEWS, season.included) 40 | context.commit(types.LOADED_ROUTE) 41 | }) 42 | .catch(function (error) { 43 | // error callback 44 | context.commit(types.ADD_TOAST, { 45 | error: 'Connection Error' 46 | }) 47 | }) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /resources/assets/sass/_cards.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Cards 3 | // -------------------------------------------------- 4 | 5 | .cards { 6 | clear: both; 7 | 8 | .card { 9 | margin-bottom: 20px; 10 | } 11 | 12 | .card img { 13 | overflow: hidden; 14 | border-radius: $border-radius-base; 15 | width: 100%; 16 | } 17 | 18 | .card span { 19 | display: block; 20 | padding: 0px 8px; 21 | max-width: 100%; 22 | white-space: nowrap; 23 | overflow: hidden; 24 | text-overflow: ellipsis; 25 | font-size: 0.8em; 26 | } 27 | 28 | .card:nth-child(1n) { 29 | float: left; 30 | margin-right: $padding; 31 | } 32 | } 33 | 34 | @media only screen and (max-width: 767px) 35 | { 36 | .card { 37 | width: calc(100% * 1 / 2 - 15px); 38 | } 39 | 40 | .card:nth-child(2n+1) { 41 | clear: left; 42 | } 43 | 44 | .card:nth-child(2n) { 45 | margin-right: 0px !important; 46 | } 47 | } 48 | 49 | @media only screen and (min-width: 768px) and (max-width: 1023px) 50 | { 51 | .card { 52 | width: calc(100% * 1 / 3 - 20px); 53 | } 54 | 55 | .card span { 56 | font-size: 0.95em !important; 57 | } 58 | 59 | .card:nth-child(3n+1) { 60 | clear: left; 61 | } 62 | 63 | .card:nth-child(3n) { 64 | margin-right: 0px !important; 65 | } 66 | } 67 | 68 | @media only screen and (min-width: 1024px) 69 | { 70 | .card { 71 | width: calc(100% * 1 / 4 - 22.5px); 72 | } 73 | 74 | .card:nth-child(4n+1) { 75 | clear: left; 76 | } 77 | 78 | .card:nth-child(4n) { 79 | margin-right: 0px !important; 80 | } 81 | } -------------------------------------------------------------------------------- /Airflix/MovieFilters.php: -------------------------------------------------------------------------------- 1 | query; 37 | } 38 | 39 | return $this->query 40 | ->whereHas('genres', 41 | function ($query) use ($uuid) { 42 | $query->where('uuid', $uuid); 43 | }); 44 | } 45 | 46 | /** 47 | * Filter by keywords. 48 | * 49 | * @param string|null $keywords 50 | * 51 | * @return \Illuminate\Database\Eloquent\Builder 52 | */ 53 | public function keywords($keywords = null) 54 | { 55 | if(! $keywords) { 56 | return $this->query; 57 | } 58 | 59 | return $this->query 60 | ->where(function ($query) use ($keywords) { 61 | $query->where('title', 'like', 62 | '%'.$keywords.'%'); 63 | }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/ShowResultController.php: -------------------------------------------------------------------------------- 1 | shows() 43 | ->get($id); 44 | 45 | $currentPage = $request->query('page', 1); 46 | 47 | $url = $request->url(); 48 | 49 | $results = $this->showResults() 50 | ->get($show, $currentPage, $url); 51 | 52 | $transformer = $this->showResults() 53 | ->transformer(); 54 | 55 | return $this->apiResponse() 56 | ->respondWithPaginator( 57 | $results, 58 | $transformer 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/MovieResultController.php: -------------------------------------------------------------------------------- 1 | movies() 43 | ->get($id); 44 | 45 | $currentPage = $request->query('page', 1); 46 | $url = $request->url(); 47 | 48 | $results = $this->movieResults() 49 | ->get($movie, $currentPage, $url); 50 | 51 | $transformer = $this->movieResults() 52 | ->transformer(); 53 | 54 | return $this->apiResponse() 55 | ->respondWithPaginator( 56 | $results, 57 | $transformer 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /resources/assets/sass/_carousel.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Carousel 3 | // -------------------------------------------------- 4 | 5 | .carousel { 6 | list-style-type: none; 7 | padding: 0px 0px 20px 0px; 8 | white-space: nowrap; 9 | overflow-x: scroll; 10 | overflow-y: hidden; 11 | width: 100%; 12 | 13 | li{ 14 | display: inline-block; 15 | border-radius: $border-radius-base; 16 | margin-right: 10px; 17 | vertical-align: top; 18 | } 19 | 20 | img { 21 | width: 100%; 22 | } 23 | 24 | a { 25 | border-radius: $border-radius-base; 26 | display: block; 27 | line-height: 1.0em; 28 | 29 | img { 30 | border: 3px solid transparent; 31 | } 32 | 33 | &:hover { 34 | cursor: pointer; 35 | } 36 | 37 | &:hover img { 38 | border: 3px solid rgba($color-accent-green, 0.5); 39 | } 40 | 41 | &.active img { 42 | border: 3px solid $color-accent-green; 43 | } 44 | 45 | &.active:hover { 46 | cursor: default; 47 | } 48 | } 49 | } 50 | 51 | .carousel.posters { 52 | li { 53 | width: 150px; 54 | } 55 | 56 | span { 57 | display: block; 58 | padding: 0px 8px; 59 | max-width: 100%; 60 | white-space: nowrap; 61 | overflow: hidden; 62 | text-overflow: ellipsis; 63 | font-size: 0.8em; 64 | } 65 | } 66 | 67 | .carousel.backdrops { 68 | li { 69 | width: 300px; 70 | } 71 | 72 | span { 73 | display: block; 74 | padding: 0px 8px; 75 | max-width: 100%; 76 | white-space: nowrap; 77 | overflow: hidden; 78 | text-overflow: ellipsis; 79 | font-size: 0.8em; 80 | } 81 | } -------------------------------------------------------------------------------- /Airflix/ShowFilters.php: -------------------------------------------------------------------------------- 1 | query; 39 | } 40 | 41 | return $this->query 42 | ->whereHas('genres', 43 | function ($query) use ($uuid) { 44 | $query->where('uuid', $uuid); 45 | }); 46 | } 47 | 48 | /** 49 | * Filter by keywords. 50 | * 51 | * @param string $keywords 52 | * 53 | * @return \Illuminate\Database\Eloquent\Builder 54 | */ 55 | public function keywords($keywords = null) 56 | { 57 | if(! $keywords) { 58 | return $this->query; 59 | } 60 | 61 | return $this->query 62 | ->where(function ($query) use ($keywords) { 63 | $query->where('name', 'like', 64 | '%'.$keywords.'%'); 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Airflix/ShowResults.php: -------------------------------------------------------------------------------- 1 | retry(3, 38 | function () use ($show, $currentPage) { 39 | return Tmdb::getSearchApi() 40 | ->searchTv($show->folder_name, [ 41 | 'page' => $currentPage, 42 | ]); 43 | }, function () { 44 | sleep(config('airflix.tmdb.throttle_seconds')); 45 | }); 46 | 47 | $items = $search['results']; 48 | $total = $search['total_results']; 49 | 50 | $paginator = new LengthAwarePaginator( 51 | $items, $total, $perPage, $currentPage 52 | ); 53 | $paginator->setPath($url); 54 | 55 | return $paginator; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Airflix/MovieResults.php: -------------------------------------------------------------------------------- 1 | retry(3, 38 | function () use ($movie, $currentPage) { 39 | return Tmdb::getSearchApi() 40 | ->searchMovies($movie->folder_name, [ 41 | 'page' => $currentPage, 42 | ]); 43 | }, function () { 44 | sleep(config('airflix.tmdb.throttle_seconds')); 45 | }); 46 | 47 | $items = $search['results']; 48 | $total = $search['total_results']; 49 | 50 | $paginator = new LengthAwarePaginator( 51 | $items, $total, $perPage, $currentPage 52 | ); 53 | $paginator->setPath($url); 54 | 55 | return $paginator; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /resources/views/errors/503.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Be right back. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 47 | 48 | 49 |
50 |
51 |
Be right back.
52 |
53 |
54 | 55 | 56 | -------------------------------------------------------------------------------- /resources/views/errors/404.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 404 - Page Not Found 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 47 | 48 | 49 |
50 |
51 |
Page not found.
52 |
53 |
54 | 55 | 56 | -------------------------------------------------------------------------------- /resources/assets/sass/_buttons.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Buttons 3 | // -------------------------------------------------- 4 | 5 | .button { 6 | overflow: auto; 7 | display: inline-block; 8 | line-height: 1.6em; 9 | color: $color-accent-green; 10 | background-color: rgba(white, 0.1); 11 | border-radius: $border-radius-base; 12 | padding: 15px; 13 | text-align: center; 14 | margin-right: 5px; 15 | margin-bottom: 10px; 16 | transition: background-color $animation-medium; 17 | white-space: nowrap; 18 | overflow: hidden; 19 | text-overflow: ellipsis; 20 | 21 | i { 22 | float: left; 23 | padding-right: 5px; 24 | } 25 | 26 | &:hover { 27 | cursor: pointer; 28 | } 29 | 30 | &:hover, 31 | &:focus, 32 | &.focus, 33 | &:active, 34 | &.active { 35 | background-color: rgba(white, 0.2); 36 | } 37 | 38 | &.disabled, 39 | &[disabled], 40 | fieldset[disabled] & { 41 | opacity: 0.3; 42 | // IE8 filter 43 | filter: alpha(opacity=30) 44 | 45 | &:hover, 46 | &:focus { 47 | background-color: rgba(white, 0.1); 48 | } 49 | } 50 | } 51 | 52 | .button-mobile { 53 | float: left; 54 | padding: 10px; 55 | margin: 0px; 56 | 57 | i { 58 | padding-right: 1px; 59 | } 60 | } 61 | 62 | .button-right { 63 | float: right; 64 | } 65 | 66 | .button-block { 67 | display: block !important; 68 | width: 100%; 69 | } 70 | 71 | @media only screen and (max-width: 767px) 72 | { 73 | .button-mobile { 74 | display: inline-block; 75 | } 76 | .button-desktop { 77 | display: none; 78 | } 79 | } 80 | 81 | @media only screen and (min-width: 768px) 82 | { 83 | .button-mobile { 84 | display: none; 85 | } 86 | .button-desktop { 87 | display: inline-block; 88 | } 89 | } -------------------------------------------------------------------------------- /resources/views/app.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Airflix 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /config/broadcasting.php: -------------------------------------------------------------------------------- 1 | env('BROADCAST_DRIVER', 'null'), 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Broadcast Connections 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may define all of the broadcast connections that will be used 26 | | to broadcast events to other systems or over websockets. Samples of 27 | | each available type of connection are provided inside this array. 28 | | 29 | */ 30 | 31 | 'connections' => [ 32 | 33 | 'pusher' => [ 34 | 'driver' => 'pusher', 35 | 'key' => env('PUSHER_APP_KEY'), 36 | 'secret' => env('PUSHER_APP_SECRET'), 37 | 'app_id' => env('PUSHER_APP_ID'), 38 | 'options' => [ 39 | // 40 | ], 41 | ], 42 | 43 | 'redis' => [ 44 | 'driver' => 'redis', 45 | 'connection' => 'default', 46 | ], 47 | 48 | 'log' => [ 49 | 'driver' => 'log', 50 | ], 51 | 52 | 'null' => [ 53 | 'driver' => 'null', 54 | ], 55 | 56 | ], 57 | 58 | ]; 59 | -------------------------------------------------------------------------------- /bootstrap/app.php: -------------------------------------------------------------------------------- 1 | singleton( 30 | Illuminate\Contracts\Http\Kernel::class, 31 | App\Http\Kernel::class 32 | ); 33 | 34 | $app->singleton( 35 | Illuminate\Contracts\Console\Kernel::class, 36 | App\Console\Kernel::class 37 | ); 38 | 39 | $app->singleton( 40 | Illuminate\Contracts\Debug\ExceptionHandler::class, 41 | App\Exceptions\Handler::class 42 | ); 43 | 44 | /* 45 | |-------------------------------------------------------------------------- 46 | | Return The Application 47 | |-------------------------------------------------------------------------- 48 | | 49 | | This script returns the application instance. The instance is given to 50 | | the calling script so we can separate the building of the instances 51 | | from the actual running of the application and sending responses. 52 | | 53 | */ 54 | 55 | return $app; 56 | -------------------------------------------------------------------------------- /artisan: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | make(Illuminate\Contracts\Console\Kernel::class); 32 | 33 | $status = $kernel->handle( 34 | $input = new Symfony\Component\Console\Input\ArgvInput, 35 | new Symfony\Component\Console\Output\ConsoleOutput 36 | ); 37 | 38 | /* 39 | |-------------------------------------------------------------------------- 40 | | Shutdown The Application 41 | |-------------------------------------------------------------------------- 42 | | 43 | | Once Artisan has finished running. We will fire off the shutdown events 44 | | so that any final work may be done by the application before we shut 45 | | down the process. This is the last thing to happen to the request. 46 | | 47 | */ 48 | 49 | $kernel->terminate($input, $status); 50 | 51 | exit($status); 52 | -------------------------------------------------------------------------------- /app/Providers/RouteServiceProvider.php: -------------------------------------------------------------------------------- 1 | mapApiRoutes(); 39 | 40 | $this->mapWebRoutes(); 41 | 42 | // 43 | } 44 | 45 | /** 46 | * Define the "web" routes for the application. 47 | * 48 | * These routes all receive session state, CSRF protection, etc. 49 | * 50 | * @return void 51 | */ 52 | protected function mapWebRoutes() 53 | { 54 | Route::middleware('web') 55 | ->namespace($this->namespace) 56 | ->group(base_path('routes/web.php')); 57 | } 58 | 59 | /** 60 | * Define the "api" routes for the application. 61 | * 62 | * These routes are typically stateless. 63 | * 64 | * @return void 65 | */ 66 | protected function mapApiRoutes() 67 | { 68 | Route::prefix('api') 69 | ->middleware('api') 70 | ->namespace($this->namespace) 71 | ->group(base_path('routes/api.php')); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/Console/Commands/ClearHistory.php: -------------------------------------------------------------------------------- 1 | option('today'); 44 | 45 | $this->movieViews() 46 | ->clearHistory($clearToday); 47 | 48 | $this->episodeViews() 49 | ->clearHistory($clearToday); 50 | 51 | $this->line(''); 52 | 53 | $this->line( 54 | 'History: '. 55 | ($clearToday ? 'Cleared today\'s history' : 'Cleared all history') 56 | ); 57 | } 58 | 59 | /** 60 | * Inject the movie views resource. 61 | * 62 | * @return \Airflix\Contracts\MovieViews 63 | */ 64 | protected function movieViews() { 65 | return app(MovieViews::class); 66 | } 67 | 68 | /** 69 | * Inject the episode views resource. 70 | * 71 | * @return \Airflix\Contracts\EpisodeViews 72 | */ 73 | protected function episodeViews() { 74 | return app(EpisodeViews::class); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "airflix/airflix", 3 | "description": "An AirPlay friendly web interface to stream your movies and TV shows from a home server.", 4 | "keywords": ["airflix", "airplay", "tv", "movies"], 5 | "license": "MIT", 6 | "type": "project", 7 | "authors": [ 8 | { 9 | "name": "Brian Wells", 10 | "role": "Developer" 11 | } 12 | ], 13 | "require": { 14 | "php": ">=5.6.4", 15 | "guzzlehttp/guzzle": "~5.0", 16 | "laravel/framework": "5.4.*", 17 | "laravel/tinker": "^1.0", 18 | "league/fractal": "^0.14.0", 19 | "league/glide-laravel": "^1.0", 20 | "php-tmdb/laravel": "~1.0", 21 | "predis/predis": "~1.0", 22 | "ramsey/uuid": "^3.2" 23 | }, 24 | "require-dev": { 25 | "fzaninotto/faker": "~1.4", 26 | "itsgoingd/clockwork": "~1.13.1", 27 | "mockery/mockery": "0.9.*", 28 | "mpociot/laravel-test-factory-helper": "^0.4.0", 29 | "phpunit/phpunit": "~5.7" 30 | }, 31 | "autoload": { 32 | "classmap": [ 33 | "database" 34 | ], 35 | "psr-4": { 36 | "App\\": "app/", 37 | "Airflix\\": "Airflix/" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "Tests\\": "tests/" 43 | } 44 | }, 45 | "scripts": { 46 | "post-root-package-install": [ 47 | "php -r \"copy('.env.example', '.env');\"" 48 | ], 49 | "post-create-project-cmd": [ 50 | "php artisan key:generate" 51 | ], 52 | "post-install-cmd": [ 53 | "php artisan clear-compiled", 54 | "php artisan optimize" 55 | ], 56 | "post-update-cmd": [ 57 | "php artisan clear-compiled", 58 | "php artisan optimize" 59 | ] 60 | }, 61 | "config": { 62 | "preferred-install": "dist", 63 | "sort-packages": true 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | 10 | /* 11 | |-------------------------------------------------------------------------- 12 | | Register The Auto Loader 13 | |-------------------------------------------------------------------------- 14 | | 15 | | Composer provides a convenient, automatically generated class loader for 16 | | our application. We just need to utilize it! We'll simply require it 17 | | into the script here so that we don't have to worry about manual 18 | | loading any of our classes later on. It feels nice to relax. 19 | | 20 | */ 21 | 22 | require __DIR__.'/../bootstrap/autoload.php'; 23 | 24 | /* 25 | |-------------------------------------------------------------------------- 26 | | Turn On The Lights 27 | |-------------------------------------------------------------------------- 28 | | 29 | | We need to illuminate PHP development, so let us turn on the lights. 30 | | This bootstraps the framework and gets it ready for use, then it 31 | | will load up this application so that we can run it and send 32 | | the responses back to the browser and delight our users. 33 | | 34 | */ 35 | 36 | $app = require_once __DIR__.'/../bootstrap/app.php'; 37 | 38 | /* 39 | |-------------------------------------------------------------------------- 40 | | Run The Application 41 | |-------------------------------------------------------------------------- 42 | | 43 | | Once we have the application, we can handle the incoming request 44 | | through the kernel, and send the associated response back to 45 | | the client's browser allowing them to enjoy the creative 46 | | and wonderful application we have prepared for them. 47 | | 48 | */ 49 | 50 | $kernel = $app->make(Illuminate\Contracts\Http\Kernel::class); 51 | 52 | $response = $kernel->handle( 53 | $request = Illuminate\Http\Request::capture() 54 | ); 55 | 56 | $response->send(); 57 | 58 | $kernel->terminate($request, $response); 59 | -------------------------------------------------------------------------------- /app/Console/Commands/RefreshShows.php: -------------------------------------------------------------------------------- 1 | option('new'); 44 | 45 | $totalShows = $this->shows() 46 | ->refreshShows($onlyNewFolders, $this->output); 47 | 48 | $this->line( 49 | 'Refreshed: '. 50 | $totalShows.' tv shows '. 51 | 'loaded from themoviedb.org' 52 | ); 53 | 54 | $totalGenres = $this->genres() 55 | ->refreshTotalShows(); 56 | 57 | $this->line( 58 | 'Updated: '. 59 | $totalGenres.' genres '. 60 | 'with show totals' 61 | ); 62 | } 63 | 64 | /** 65 | * Inject the genres resource. 66 | * 67 | * @return \Airflix\Contracts\Genres 68 | */ 69 | protected function genres() { 70 | return app(Genres::class); 71 | } 72 | 73 | /** 74 | * Inject the shows resource. 75 | * 76 | * @return \Airflix\Contracts\Shows 77 | */ 78 | protected function shows() { 79 | return app(Shows::class); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /resources/assets/js/vuex/mutation-types.js: -------------------------------------------------------------------------------- 1 | // Storage 2 | export const ADD_EPISODE = 'ADD_EPISODE' 3 | export const ADD_EPISODES = 'ADD_EPISODES' 4 | export const ADD_GENRES = 'ADD_GENRES' 5 | export const ADD_IMAGES = 'ADD_IMAGES' 6 | export const ADD_MOVIE = 'ADD_MOVIE' 7 | export const ADD_MOVIES = 'ADD_MOVIES' 8 | export const ADD_SEASON = 'ADD_SEASON' 9 | export const ADD_SEASONS = 'ADD_SEASONS' 10 | export const ADD_SHOW = 'ADD_SHOW' 11 | export const ADD_SHOWS = 'ADD_SHOWS' 12 | export const ADD_TOAST = 'ADD_TOAST' 13 | // Pagination 14 | export const LOADED_MOVIES = 'LOADED_MOVIES' 15 | export const LOADED_SHOWS = 'LOADED_SHOWS' 16 | export const LOADING_MOVIES = 'LOADING_MOVIES' 17 | export const LOADING_SHOWS = 'LOADING_SHOWS' 18 | export const ADD_MOVIE_LINKS = 'ADD_MOVIE_LINKS' 19 | export const ADD_SHOW_LINKS = 'ADD_SHOW_LINKS' 20 | // Settings 21 | export const ADD_SETTINGS = 'ADD_SETTINGS' 22 | export const CLEAR_ALL = 'CLEAR_ALL' 23 | export const SET_SETTINGS_ATTRIBUTES = 'SET_SETTINGS_ATTRIBUTES' 24 | // Search Results 25 | export const ADD_MOVIE_RESULTS = 'ADD_MOVIE_RESULTS' 26 | export const ADD_SHOW_RESULTS = 'ADD_SHOW_RESULTS' 27 | // Interface 28 | export const LOADED_ROUTE = 'LOADED_ROUTE' 29 | export const LOADING_ROUTE = 'LOADING_ROUTE' 30 | export const HIDE_MENU = 'HIDE_MENU' 31 | export const TOGGLE_MENU = 'TOGGLE_MENU' 32 | export const TOGGLE_SEARCH = 'TOGGLE_SEARCH' 33 | // Filters 34 | export const CLEAR_FILTERS = 'CLEAR_FILTERS' 35 | export const CLEAR_GENRES_FILTER = 'CLEAR_GENRES_FILTER' 36 | export const FILTER_GENRES = 'FILTER_GENRES' 37 | export const FILTER_KEYWORDS = 'FILTER_KEYWORDS' 38 | export const FILTER_ORDER = 'FILTER_ORDER' 39 | // Selections 40 | export const SELECT_EPISODE = 'SELECT_EPISODE' 41 | export const SELECT_MOVIE = 'SELECT_MOVIE' 42 | export const SELECT_SEASON = 'SELECT_SEASON' 43 | export const SELECT_SHOW = 'SELECT_SHOW' 44 | // Views 45 | export const ADD_MOVIE_VIEWS = 'ADD_MOVIE_VIEWS' 46 | export const ADD_SEASON_VIEWS = 'ADD_SEASON_VIEWS' 47 | export const ADD_SHOW_VIEWS = 'ADD_SHOW_VIEWS' 48 | -------------------------------------------------------------------------------- /app/Console/Commands/RefreshMovies.php: -------------------------------------------------------------------------------- 1 | option('new'); 44 | 45 | $totalMovies = $this->movies() 46 | ->refreshMovies($onlyNewFolders, $this->output); 47 | 48 | $this->line( 49 | 'Refreshed: '. 50 | $totalMovies.' movies '. 51 | 'loaded from themoviedb.org' 52 | ); 53 | 54 | $totalGenres = $this->genres() 55 | ->refreshTotalMovies(); 56 | 57 | $this->line( 58 | 'Updated: '. 59 | $totalGenres.' genres '. 60 | 'with movie totals' 61 | ); 62 | } 63 | 64 | /** 65 | * Inject the genres resource. 66 | * 67 | * @return \Airflix\Contracts\Genres 68 | */ 69 | protected function genres() { 70 | return app(Genres::class); 71 | } 72 | 73 | /** 74 | * Inject the movies resource. 75 | * 76 | * @return \Airflix\Contracts\Movies 77 | */ 78 | protected function movies() { 79 | return app(Movies::class); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Airflix/V1/GenreTransformer.php: -------------------------------------------------------------------------------- 1 | $genre->uuid, 37 | 'name' => $genre->name, 38 | 'total_movies' => $genre->total_movies, 39 | 'total_shows' => $genre->total_shows, 40 | ]; 41 | } 42 | 43 | /** 44 | * Include movies 45 | * 46 | * @param \Airflix\Genre $genre 47 | * 48 | * @return \League\Fractal\Resource\Collection 49 | */ 50 | public function includeMovies($genre) 51 | { 52 | $movies = $genre->movies; 53 | 54 | $transformer = app( 55 | Contracts\MovieTransformer::class 56 | ); 57 | 58 | return $this->collection( 59 | $movies, 60 | $transformer, 61 | $transformer->resourceType 62 | ); 63 | } 64 | 65 | /** 66 | * Include shows 67 | * 68 | * @param \Airflix\Genre $genre 69 | * 70 | * @return \League\Fractal\Resource\Shows 71 | */ 72 | public function includeShows($genre) 73 | { 74 | $shows = $genre->shows; 75 | 76 | $transformer = app( 77 | Contracts\ShowTransformer::class 78 | ); 79 | 80 | return $this->collection( 81 | $shows, 82 | $transformer, 83 | $transformer->resourceType 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /app/Console/Commands/SetAPIKeys.php: -------------------------------------------------------------------------------- 1 | option('tmdb'); 43 | 44 | if (!$tmdbApiKey && config('tmdb.api_key') == 'ApplyForAnApiKey') { 45 | $tmdbApiKey = $this->ask( 46 | 'What is your themoviedb.org API key?' 47 | ); 48 | } 49 | 50 | if($tmdbApiKey) { 51 | $this->setKeyInEnvironmentFile( 52 | $tmdbApiKey, 53 | $this->laravel['config']['tmdb.api_key'], 54 | 'TMDB_API_KEY' 55 | ); 56 | 57 | $this->laravel['config']['tmdb.api_key'] = $tmdbApiKey; 58 | 59 | $this->info('TMDB API key ['.$tmdbApiKey.'] set successfully.'); 60 | } 61 | } 62 | 63 | /** 64 | * Set a variable key in the environment file. 65 | * 66 | * @param string $new 67 | * @param string $current 68 | * @param string $envKey 69 | */ 70 | protected function setKeyInEnvironmentFile($new, $current, $envKey) { 71 | file_put_contents( 72 | $this->laravel->environmentFilePath(), 73 | str_replace( 74 | $envKey.'='.$current, 75 | $envKey.'='.$new, 76 | file_get_contents($this->laravel->environmentFilePath()) 77 | ) 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Airflix/ShowImages.php: -------------------------------------------------------------------------------- 1 | retry(3, 33 | function () use ($show) { 34 | return Tmdb::getTvApi() 35 | ->getImages($show->tmdb_show_id, [ 36 | 'include_image_language' => 37 | config('airflix.tmdb.languages'), 38 | ]); 39 | }, function () { 40 | sleep(config('airflix.tmdb.throttle_seconds')); 41 | }); 42 | } 43 | 44 | /** 45 | * Get tmdb show backdrops. 46 | * 47 | * @param \Airflix\Show $show 48 | * 49 | * @return \Illuminate\Support\Collection 50 | */ 51 | public function getBackdrops($show) 52 | { 53 | if ($show->tmdb_show_id == 0) { 54 | return []; 55 | } 56 | 57 | $images = $this->get($show); 58 | 59 | // Remove any duplicates with the keyBy collection method 60 | return collect($images['backdrops']) 61 | ->keyBy('file_path') 62 | ->all(); 63 | } 64 | 65 | /** 66 | * Get tmdb show posters. 67 | * 68 | * @param \Airflix\Show $show 69 | * 70 | * @return \Illuminate\Support\Collection 71 | */ 72 | public function getPosters($show) 73 | { 74 | if ($show->tmdb_show_id == 0) { 75 | return []; 76 | } 77 | 78 | $images = $this->get($show); 79 | 80 | // Remove any duplicates with the keyBy collection method 81 | return collect($images['posters']) 82 | ->keyBy('file_path') 83 | ->all(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Airflix/MovieImages.php: -------------------------------------------------------------------------------- 1 | retry(3, 33 | function () use ($movie) { 34 | return Tmdb::getMoviesApi() 35 | ->getImages($movie->tmdb_movie_id, [ 36 | 'include_image_language' => 37 | config('airflix.tmdb.languages'), 38 | ]); 39 | }, function () { 40 | sleep(config('airflix.tmdb.throttle_seconds')); 41 | }); 42 | } 43 | 44 | /** 45 | * Get tmdb movie backdrops. 46 | * 47 | * @param \Airflix\Movie $movie 48 | * 49 | * @return \Illuminate\Support\Collection 50 | */ 51 | public function getBackdrops($movie) 52 | { 53 | if ($movie->tmdb_movie_id == 0) { 54 | return []; 55 | } 56 | 57 | $images = $this->get($movie); 58 | 59 | // Remove any duplicates with the keyBy collection method 60 | return collect($images['backdrops']) 61 | ->keyBy('file_path') 62 | ->all(); 63 | } 64 | 65 | /** 66 | * Get tmdb movie posters. 67 | * 68 | * @param \Airflix\Movie $movie 69 | * 70 | * @return \Illuminate\Support\Collection 71 | */ 72 | public function getPosters($movie) 73 | { 74 | if ($movie->tmdb_movie_id == 0) { 75 | return []; 76 | } 77 | 78 | $images = $this->get($movie); 79 | 80 | // Remove any duplicates with the keyBy collection method 81 | return collect($images['posters']) 82 | ->keyBy('file_path') 83 | ->all(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/Http/Kernel.php: -------------------------------------------------------------------------------- 1 | [ 31 | \App\Http\Middleware\EncryptCookies::class, 32 | \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, 33 | \Illuminate\Session\Middleware\StartSession::class, 34 | // \Illuminate\Session\Middleware\AuthenticateSession::class, 35 | \Illuminate\View\Middleware\ShareErrorsFromSession::class, 36 | \App\Http\Middleware\VerifyCsrfToken::class, 37 | \Illuminate\Routing\Middleware\SubstituteBindings::class, 38 | ], 39 | 40 | 'api' => [ 41 | 'throttle:120,1', 42 | 'bindings', 43 | ], 44 | ]; 45 | 46 | /** 47 | * The application's route middleware. 48 | * 49 | * These middleware may be assigned to groups or used individually. 50 | * 51 | * @var array 52 | */ 53 | protected $routeMiddleware = [ 54 | 'auth' => \Illuminate\Auth\Middleware\Authenticate::class, 55 | 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 56 | 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class, 57 | 'can' => \Illuminate\Auth\Middleware\Authorize::class, 58 | 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 59 | 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 60 | ]; 61 | } 62 | -------------------------------------------------------------------------------- /config/cache.php: -------------------------------------------------------------------------------- 1 | env('CACHE_DRIVER', 'redis'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Cache Stores 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Here you may define all of the cache "stores" for your application as 24 | | well as their drivers. You may even define multiple stores for the 25 | | same cache driver to group types of items stored in your caches. 26 | | 27 | */ 28 | 29 | 'stores' => [ 30 | 31 | 'apc' => [ 32 | 'driver' => 'apc', 33 | ], 34 | 35 | 'array' => [ 36 | 'driver' => 'array', 37 | ], 38 | 39 | 'database' => [ 40 | 'driver' => 'database', 41 | 'table' => 'cache', 42 | 'connection' => null, 43 | ], 44 | 45 | 'file' => [ 46 | 'driver' => 'file', 47 | 'path' => storage_path('framework/cache/data'), 48 | ], 49 | 50 | 'memcached' => [ 51 | 'driver' => 'memcached', 52 | 'servers' => [ 53 | [ 54 | 'host' => '127.0.0.1', 'port' => 11211, 'weight' => 100, 55 | ], 56 | ], 57 | ], 58 | 59 | 'redis' => [ 60 | 'driver' => 'redis', 61 | 'connection' => 'default', 62 | ], 63 | 64 | ], 65 | 66 | /* 67 | |-------------------------------------------------------------------------- 68 | | Cache Key Prefix 69 | |-------------------------------------------------------------------------- 70 | | 71 | | When utilizing a RAM based store such as APC or Memcached, there might 72 | | be other applications utilizing the same cache. So, we'll specify a 73 | | value to get prefixed to all our keys so we can avoid collisions. 74 | | 75 | */ 76 | 77 | 'prefix' => 'airflix', 78 | 79 | ]; 80 | -------------------------------------------------------------------------------- /database/migrations/2016_02_20_151342_create_movies_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 17 | $table->uuid('uuid')->unique(); 18 | $table->integer('tmdb_movie_id')->index()->unsigned()->default(0); 19 | $table->string('title')->nullable(); 20 | $table->string('original_title')->nullable(); 21 | $table->string('folder_name'); 22 | $table->string('folder_path', 4096); 23 | $table->text('overview')->nullable(); 24 | $table->string('homepage')->nullable(); 25 | $table->string('poster_path')->nullable(); 26 | $table->string('backdrop_path')->nullable(); 27 | $table->integer('runtime')->unsigned()->default(0); 28 | $table->float('popularity')->default(0.00); 29 | $table->date('release_date')->nullable(); 30 | $table->bigInteger('budget')->unsigned()->default(0); 31 | $table->bigInteger('revenue')->unsigned()->default(0); 32 | $table->integer('total_views')->unsigned()->default(0); 33 | $table->string('imdb_id')->nullable(); 34 | $table->timestamps(); 35 | }); 36 | 37 | Schema::create('movie_views', function (Blueprint $table) { 38 | $table->increments('id'); 39 | $table->integer('movie_id')->unsigned(); 40 | $table->uuid('uuid')->unique(); 41 | $table->uuid('movie_uuid'); 42 | $table->integer('tmdb_movie_id')->unsigned()->default(0); 43 | $table->timestamps(); 44 | }); 45 | 46 | Schema::create('genre_movie', function (Blueprint $table) { 47 | $table->integer('genre_id')->unsigned(); 48 | $table->integer('movie_id')->unsigned(); 49 | 50 | $table->primary(['genre_id', 'movie_id']); 51 | }); 52 | } 53 | 54 | /** 55 | * Reverse the migrations. 56 | * 57 | * @return void 58 | */ 59 | public function down() 60 | { 61 | Schema::dropIfExists('genre_movie'); 62 | Schema::dropIfExists('movie_views'); 63 | Schema::dropIfExists('movies'); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /resources/assets/sass/_spinner.scss: -------------------------------------------------------------------------------- 1 | // Spinner 2 | // -------------------------------------------------- 3 | 4 | 5 | // Animation 6 | @keyframes spin { 7 | 100% { 8 | transform: rotate(360deg); 9 | } 10 | } 11 | 12 | // Core stuff 13 | .spinner { 14 | width: 100%; 15 | text-align: center; 16 | 17 | // fixed position is better option for full screen spinner overlay 18 | &.spinner-fixed { 19 | top: 0; 20 | left: 0; 21 | bottom: 0; 22 | right: 0; 23 | z-index: 9998; 24 | position: fixed; 25 | background: $spinner-backdrop-background; 26 | } 27 | 28 | // wraps text and spinner itself and centers it 29 | .spinner-wrapper { 30 | padding: 10px; 31 | position: absolute; 32 | top: 50%; 33 | left: 50%; 34 | transform: translate(-50%, -50%); 35 | // fix for IE9 36 | -ms-transform: translate(-50%, -50%); 37 | } 38 | 39 | // animated spinner 40 | .spinner-circle { 41 | position: relative; 42 | border: $spinner-border-size solid $spinner-border-secondary-color; 43 | border-right-color: $spinner-border-primary-color; 44 | border-radius: 50%; 45 | display: inline-block; 46 | animation: spin 0.6s linear; 47 | animation-iteration-count: infinite; 48 | width: 3em; 49 | height: 3em; 50 | z-index: 2; 51 | } 52 | 53 | // a text below spinner 54 | .spinner-text { 55 | position: relative; 56 | text-align: center; 57 | margin-top: 0.5em; 58 | z-index: 2; 59 | width: 100%; 60 | font-size: 95%; 61 | color: $spinner-text-color; 62 | } 63 | } 64 | 65 | // Sizes 66 | .spinner { 67 | &.spinner-sm .spinner-circle { 68 | width: $spinner-sm; 69 | height: $spinner-sm; 70 | } 71 | &.spinner-md .spinner-circle { 72 | width: $spinner-md; 73 | height: $spinner-md; 74 | } 75 | &.spinner-lg .spinner-circle { 76 | width: $spinner-lg; 77 | height: $spinner-lg; 78 | } 79 | &.spinner-xl .spinner-circle { 80 | width: $spinner-xl; 81 | height: $spinner-xl; 82 | } 83 | } 84 | 85 | // Default to standard gif for < IE10 86 | .lt-ie10, .ie9, .oldie, .no-csstransitions, .no-csstransforms3d { 87 | .spinner .spinner-circle { 88 | background: url("http://i2.wp.com/www.thegreatnovelingadventure.com/wp-content/plugins/wp-polls/images/loading.gif") center center no-repeat; 89 | animation: none; 90 | margin-left: 0; 91 | margin-top: 5px; 92 | border: none; 93 | width: 32px; 94 | height: 32px; 95 | } 96 | } -------------------------------------------------------------------------------- /app/Exceptions/Handler.php: -------------------------------------------------------------------------------- 1 | ajax()) { 51 | return $this->apiResponse() 52 | ->renderException($e); 53 | } 54 | 55 | return parent::render($request, $e); 56 | } 57 | 58 | /** 59 | * Convert an authentication exception into an unauthenticated response. 60 | * 61 | * @param \Illuminate\Http\Request $request 62 | * @param \Illuminate\Auth\AuthenticationException $exception 63 | * @return \Illuminate\Http\Response 64 | */ 65 | protected function unauthenticated($request, AuthenticationException $exception) 66 | { 67 | if ($request->expectsJson()) { 68 | return response()->json(['error' => 'Unauthenticated.'], 401); 69 | } 70 | 71 | return redirect()->guest('login'); 72 | } 73 | 74 | /** 75 | * Inject the api response factory. 76 | * 77 | * @return \Airflix\Contracts\ApiResponse 78 | */ 79 | protected function apiResponse() 80 | { 81 | return app(ApiResponse::class); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/Unit/RetriableTest.php: -------------------------------------------------------------------------------- 1 | traitObject = new RetriesImpl; 19 | } 20 | 21 | /** @test */ 22 | function it_retries_without_failing() 23 | { 24 | $i = 0; 25 | $value = $this->traitObject->retry(1, 26 | function () use (&$i) { 27 | $i++; 28 | return 5; 29 | }); 30 | 31 | $this->assertSame(1, $i); 32 | $this->assertSame(5, $value); 33 | } 34 | 35 | /** @test */ 36 | function it_retries_failing_once() 37 | { 38 | $i = 0; 39 | $failed = false; 40 | $value = $this->traitObject->retry(1, 41 | function () use (&$i, &$failed) { 42 | $i++; 43 | if (!$failed) { 44 | $failed = true; 45 | throw new \RuntimeException('roflcopter'); 46 | } 47 | return 5; 48 | }); 49 | 50 | $this->assertSame(2, $i); 51 | $this->assertSame(5, $value); 52 | } 53 | 54 | /** @test */ 55 | function it_retries_failing_too_hard() 56 | { 57 | $e = null; 58 | $i = 0; 59 | try { 60 | $this->traitObject->retry(1, 61 | function () use (&$i) { 62 | $i++; 63 | throw new \RuntimeException('rofl'); 64 | }); 65 | } catch (\Exception $e) { 66 | } 67 | 68 | $this->assertInstanceof('Airflix\FailingTooHard', $e); 69 | $this->assertInstanceof('RuntimeException', $e->getPrevious()); 70 | $this->assertSame('rofl', $e->getPrevious()->getMessage()); 71 | $this->assertSame(2, $i); 72 | } 73 | 74 | /** @test */ 75 | function it_retries_many_times() 76 | { 77 | $e = null; 78 | $i = 0; 79 | try { 80 | $this->traitObject->retry(1000, 81 | function () use (&$i) { 82 | $i++; 83 | throw new \RuntimeException('dogecoin'); 84 | }); 85 | } catch (\Exception $e) { 86 | } 87 | 88 | $this->assertInstanceof('Airflix\FailingTooHard', $e); 89 | $this->assertInstanceof('RuntimeException', $e->getPrevious()); 90 | $this->assertSame('dogecoin', $e->getPrevious()->getMessage()); 91 | $this->assertSame(1001, $i); 92 | } 93 | } -------------------------------------------------------------------------------- /resources/assets/sass/_navigation.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Navigation 3 | // -------------------------------------------------- 4 | 5 | #navigation { 6 | width: 50%; 7 | min-height: 100%; 8 | height: 100%; 9 | position: fixed; 10 | top: 0; 11 | left: -50%; 12 | background: $bg-navigation; 13 | overflow-y: scroll; 14 | transition: left $animation-fast ease; 15 | 16 | .button-mobile { 17 | position: absolute; 18 | top: calc(50% - 22px); 19 | right: 0; 20 | border-radius: 4px 0px 0px 4px; 21 | } 22 | 23 | ul { 24 | list-style-type: none; 25 | padding: 0px; 26 | margin-top: 10px; 27 | 28 | li { 29 | position: relative; 30 | 31 | a { 32 | display: block; 33 | padding: $padding / 2; 34 | padding-left: $padding; 35 | color: $color-font-light; 36 | transition: background-color $animation-medium; 37 | line-height: 1.6em; 38 | 39 | i { 40 | float: left; 41 | padding-right: 10px; 42 | } 43 | 44 | &:hover { 45 | background-color: rgba(white, 0.05); 46 | } 47 | } 48 | 49 | &.active, &.router-link-active { 50 | a { 51 | color: $color-font; 52 | padding-left: $padding - 4px; 53 | border-left: 4px solid $color-accent-green; 54 | background-color: rgba(white, 0.1); 55 | } 56 | } 57 | } 58 | } 59 | } 60 | 61 | #overlay { 62 | width: 100%; 63 | height: 100%; 64 | position: fixed; 65 | top: 0; 66 | left: 0; 67 | background: rgba(0,0,0,0); 68 | z-index: -1; 69 | transition: background $animation-fast ease; 70 | } 71 | 72 | #overlay:hover { 73 | cursor: pointer; 74 | } 75 | 76 | #content, #footer { 77 | width: 100%; 78 | position: relative; 79 | transition: left $animation-fast ease; 80 | left: 0; 81 | } 82 | 83 | @media only screen and (max-width: 767px) 84 | { 85 | .toggle-menu#app { 86 | overflow-y: hidden; 87 | } 88 | .toggle-menu #header, 89 | .toggle-menu #content, 90 | .toggle-menu #footer { 91 | left: 50%; 92 | } 93 | .toggle-menu #navigation { 94 | left: 0; 95 | z-index: 2; 96 | } 97 | .toggle-menu #overlay { 98 | display: block; 99 | z-index: 1; 100 | background: rgba(0,0,0,0.5); 101 | } 102 | } 103 | 104 | @media only screen and (min-width: 768px) 105 | { 106 | #navigation { 107 | width: 200px; 108 | left: 0; 109 | } 110 | #content { 111 | left: 200px; 112 | width: calc(100% - 200px); 113 | max-width: 1080px; 114 | } 115 | #footer { 116 | left: 200px; 117 | width: calc(100% - 200px); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /config/queue.php: -------------------------------------------------------------------------------- 1 | env('QUEUE_DRIVER', 'redis'), 20 | 21 | /* 22 | |-------------------------------------------------------------------------- 23 | | Queue Connections 24 | |-------------------------------------------------------------------------- 25 | | 26 | | Here you may configure the connection information for each server that 27 | | is used by your application. A default configuration has been added 28 | | for each back-end shipped with Laravel. You are free to add more. 29 | | 30 | */ 31 | 32 | 'connections' => [ 33 | 34 | 'sync' => [ 35 | 'driver' => 'sync', 36 | ], 37 | 38 | 'database' => [ 39 | 'driver' => 'database', 40 | 'table' => 'jobs', 41 | 'queue' => 'default', 42 | 'expire' => 60, 43 | ], 44 | 45 | 'beanstalkd' => [ 46 | 'driver' => 'beanstalkd', 47 | 'host' => 'localhost', 48 | 'queue' => 'default', 49 | 'ttr' => 60, 50 | ], 51 | 52 | 'sqs' => [ 53 | 'driver' => 'sqs', 54 | 'key' => 'your-public-key', 55 | 'secret' => 'your-secret-key', 56 | 'prefix' => 'https://sqs.us-east-1.amazonaws.com/your-account-id', 57 | 'queue' => 'your-queue-name', 58 | 'region' => 'us-east-1', 59 | ], 60 | 61 | 'redis' => [ 62 | 'driver' => 'redis', 63 | 'connection' => 'default', 64 | 'queue' => 'default', 65 | 'expire' => 60, 66 | ], 67 | 68 | ], 69 | 70 | /* 71 | |-------------------------------------------------------------------------- 72 | | Failed Queue Jobs 73 | |-------------------------------------------------------------------------- 74 | | 75 | | These options configure the behavior of failed queue job logging so you 76 | | can control which database and table are used to store the jobs that 77 | | have failed. You may change them to any database / table you wish. 78 | | 79 | */ 80 | 81 | 'failed' => [ 82 | 'database' => env('DB_CONNECTION', 'mysql'), 83 | 'table' => 'failed_jobs', 84 | ], 85 | 86 | ]; 87 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/Settings/SettingsController.php: -------------------------------------------------------------------------------- 1 | apiResponse() 32 | ->respondWithArray( 33 | $this->settings() 34 | ->get() 35 | ); 36 | } 37 | 38 | /** 39 | * Set the folder paths for the application. 40 | * 41 | * @param \Illuminate\Http\Request $request 42 | * 43 | * @return \Illuminate\Http\Response 44 | */ 45 | public function patch(Request $request) 46 | { 47 | $this->validate($request, [ 48 | 'data.attributes.movies_folder' => 49 | 'required_without:data.attributes.shows_folder|string', 50 | 'data.attributes.shows_folder' => 51 | 'required_without:data.attributes.movies_folder|string', 52 | ]); 53 | 54 | $input = $request->input(); 55 | $type = 'settings'; 56 | 57 | // 403 - Unsupported request format 58 | if (! $this->validRequestStructure($input)) { 59 | return $this->apiResponse() 60 | ->errorForbidden( 61 | 'Unsupported request format (requires JSON-API).' 62 | ); 63 | } 64 | 65 | // 409 - Incorrect id or type 66 | if (! $this->validRequestData($input, 0, $type)) { 67 | return $this->apiResponse() 68 | ->errorConflict( 69 | 'Bad request data (verify id and type).' 70 | ); 71 | } 72 | 73 | $attributes = $request->input('data.attributes'); 74 | 75 | if (isset($attributes['movies_folder'])) { 76 | $options['--movies'] = $attributes['movies_folder']; 77 | } 78 | 79 | if (isset($attributes['shows_folder'])) { 80 | $options['--shows'] = $attributes['shows_folder']; 81 | } 82 | 83 | $exitCode = Artisan::call('airflix:folders', $options); 84 | 85 | if ($exitCode == 255) { 86 | $this->dispatch(new RefreshAll()); 87 | } 88 | 89 | return $this->apiResponse() 90 | ->respondWithArray( 91 | $this->settings() 92 | ->get() 93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Airflix/MovieViews.php: -------------------------------------------------------------------------------- 1 | where('movie_id', 0) 45 | ->update([ 46 | 'movie_id' => $movie->id, 47 | 'movie_uuid' => $movie->uuid, 48 | ]); 49 | } 50 | 51 | /** 52 | * Unlink all movie views from their movies. 53 | * 54 | * @return void 55 | */ 56 | public function unlink() 57 | { 58 | return MovieView::where('movie_id', '<>', 0) 59 | ->update([ 60 | 'movie_id' => 0, 61 | 'movie_uuid' => null, 62 | ]); 63 | } 64 | 65 | /** 66 | * Create a movie view within time limit. 67 | * 68 | * @param \Airflix\Movie $movie 69 | * @param integer $timeLimit 70 | * 71 | * @return \Airflix\MovieView 72 | */ 73 | public function watch($movie, $timeLimit = null) 74 | { 75 | $timeLimit = $timeLimit ?: 76 | Carbon::now() 77 | ->subMinutes($movie->runtime + 60); 78 | 79 | $view = MovieView::where('created_at', '>=', $timeLimit) 80 | ->firstOrNew([ 81 | 'movie_id' => $movie->id, 82 | 'movie_uuid' => $movie->uuid, 83 | 'tmdb_movie_id' => $movie->tmdb_movie_id, 84 | ]); 85 | 86 | $view->save(); 87 | 88 | $this->movies() 89 | ->updateTotalViews($movie); 90 | 91 | return $view; 92 | } 93 | 94 | /** 95 | * Delete movie views, either from today or all time. 96 | * 97 | * @param bool $clearToday 98 | * 99 | * @return bool 100 | */ 101 | public function clearHistory($clearToday = false) 102 | { 103 | if($clearToday) { 104 | $startOfDay = Carbon::now()->startOfDay(); 105 | 106 | return MovieView::where('created_at', '>=', $startOfDay) 107 | ->delete(); 108 | } 109 | 110 | return MovieView::truncate(); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Airflix/Episodes.php: -------------------------------------------------------------------------------- 1 | with($relationships) 50 | ->where('uuid', $id) 51 | ->where('air_date', '<=', Carbon::now()) 52 | ->firstOrFail(); 53 | } 54 | 55 | /** 56 | * Update the total views of an episode. 57 | * 58 | * @param \Airflix\Episode $episode 59 | * 60 | * @return \Airflix\Episode 61 | */ 62 | public function updateTotalViews($episode) 63 | { 64 | $episode->total_views = $episode->views()->count(); 65 | 66 | $episode->save(); 67 | 68 | return $episode; 69 | } 70 | 71 | /** 72 | * Update the episode with data from the tmdb API. 73 | * 74 | * @param array $result 75 | * @param \Airflix\Show $show 76 | * @param \Airflix\Season $season 77 | * 78 | * @return \Airflix\Episode 79 | */ 80 | public function refreshEpisode($result, $show, $season) 81 | { 82 | $episode = Episode::firstOrNew([ 83 | 'tmdb_episode_id' => $result['id'], 84 | ]); 85 | 86 | $episode->show_id = $show->id; 87 | $episode->season_id = $season->id; 88 | $episode->show_uuid = $show->uuid; 89 | $episode->season_uuid = $season->uuid; 90 | 91 | $episode->save(); 92 | 93 | $episode->update(array_merge($result, [ 94 | 'tmdb_season_id' => $season->tmdb_season_id, 95 | 'tmdb_show_id' => $show->tmdb_show_id, 96 | ])); 97 | 98 | // Update total Episode Views 99 | $this->updateTotalViews($episode); 100 | 101 | $this->imageClient() 102 | ->download($episode->still_path, 'episodes'); 103 | 104 | return $episode; 105 | } 106 | 107 | /** 108 | * Truncate the episodes table. 109 | * 110 | * @return void 111 | */ 112 | public function truncate() 113 | { 114 | return Episode::truncate(); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Airflix/V1/SeasonTransformer.php: -------------------------------------------------------------------------------- 1 | $season->uuid, 38 | 'show_id' => $season->show_uuid, 39 | 'season_number' => (int) $season->season_number, 40 | 'name' => $season->name, 41 | 'overview' => $season->overview, 42 | 'poster_url' => $season->poster_url, 43 | 'number_of_episodes' => (int) $season->episode_count, 44 | 'air_date' => $season->air_date ? 45 | $season->air_date->toIso8601String() : null, 46 | 'total_views' => (int) $season->total_views, 47 | ]; 48 | } 49 | 50 | /** 51 | * Include episodes 52 | * 53 | * @param \Airflix\Season $season 54 | * 55 | * @return \League\Fractal\Resource\Collection 56 | */ 57 | public function includeEpisodes($season) 58 | { 59 | $episodes = $season->episodes; 60 | 61 | $transformer = app( 62 | Contracts\EpisodeTransformer::class 63 | ); 64 | 65 | return $this->collection( 66 | $episodes, 67 | $transformer, 68 | $transformer->resourceType 69 | ); 70 | } 71 | 72 | /** 73 | * Include show 74 | * 75 | * @param \Airflix\Season $season 76 | * 77 | * @return \League\Fractal\Resource\Item 78 | */ 79 | public function includeShow($season) 80 | { 81 | $show = $season->show; 82 | 83 | $transformer = app( 84 | Contracts\ShowTransformer::class 85 | ); 86 | 87 | return $this->item( 88 | $show, 89 | $transformer, 90 | $transformer->resourceType 91 | ); 92 | } 93 | 94 | /** 95 | * Include views 96 | * 97 | * @param \Airflix\Season $season 98 | * 99 | * @return \League\Fractal\Resource\Collection 100 | */ 101 | public function includeViews($season) 102 | { 103 | $views = $season->monthlyViews(); 104 | 105 | $transformer = app( 106 | Contracts\SeasonViewMonthlyTransformer::class 107 | ); 108 | 109 | return $this->collection( 110 | $views, 111 | $transformer, 112 | $transformer->resourceType 113 | ); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /config/filesystems.php: -------------------------------------------------------------------------------- 1 | 'public', 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Default Cloud Filesystem Disk 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Many applications store files both locally and in the cloud. For this 26 | | reason, you may specify a default "cloud" driver here. This driver 27 | | will be bound as the Cloud disk implementation in the container. 28 | | 29 | */ 30 | 31 | 'cloud' => 's3', 32 | 33 | /* 34 | |-------------------------------------------------------------------------- 35 | | Filesystem Disks 36 | |-------------------------------------------------------------------------- 37 | | 38 | | Here you may configure as many filesystem "disks" as you wish, and you 39 | | may even configure multiple disks of the same driver. Defaults have 40 | | been setup for each driver as an example of the required options. 41 | | 42 | */ 43 | 44 | 'disks' => [ 45 | 46 | 'public' => [ 47 | 'driver' => 'local', 48 | 'root' => public_path(), 49 | 'url' => env('APP_URL').'/storage', 50 | 'visibility' => 'public', 51 | ], 52 | 53 | 'app' => [ 54 | 'driver' => 'local', 55 | 'root' => storage_path('app'), 56 | 'visibility' => 'public', 57 | ], 58 | 59 | 'ftp' => [ 60 | 'driver' => 'ftp', 61 | 'host' => 'ftp.example.com', 62 | 'username' => 'your-username', 63 | 'password' => 'your-password', 64 | 65 | // Optional FTP Settings... 66 | // 'port' => 21, 67 | // 'root' => '', 68 | // 'passive' => true, 69 | // 'ssl' => true, 70 | // 'timeout' => 30, 71 | ], 72 | 73 | 's3' => [ 74 | 'driver' => 's3', 75 | 'key' => env('AWS_KEY'), 76 | 'secret' => env('AWS_SECRET'), 77 | 'region' => env('AWS_REGION'), 78 | 'bucket' => env('AWS_BUCKET'), 79 | ], 80 | 81 | 'rackspace' => [ 82 | 'driver' => 'rackspace', 83 | 'username' => 'your-username', 84 | 'key' => 'your-key', 85 | 'container' => 'your-container', 86 | 'endpoint' => 'https://identity.api.rackspacecloud.com/v2.0/', 87 | 'region' => 'IAD', 88 | 'url_type' => 'publicURL', 89 | ], 90 | 91 | ], 92 | 93 | ]; 94 | -------------------------------------------------------------------------------- /resources/assets/sass/_toasts.scss: -------------------------------------------------------------------------------- 1 | // variables 2 | $toast-background-default: #818a91 !default; 3 | $toast-background-dark: #000 !default; 4 | $toast-background-light: #fff !default; 5 | $toast-background-primary: $color-accent-blue !default; 6 | $toast-background-info: $color-accent-turquoise !default; 7 | $toast-background-success: $color-accent-green !default; 8 | $toast-background-warning: $color-accent-orange !default; 9 | $toast-background-danger: $color-accent-red !default; 10 | 11 | // mixin 12 | @mixin toast-context ($background, $color) { 13 | background-color: $background; 14 | color: $color; 15 | } 16 | 17 | // core styling 18 | .toast { 19 | display: table; 20 | position: fixed; 21 | min-height: 44px; 22 | min-width: 288px; 23 | max-width: 600px; 24 | padding: 10px 15px; 25 | box-sizing: border-box; 26 | box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26); 27 | border-radius: $border-radius-base; 28 | left: 0; 29 | bottom: 0; 30 | margin: ($padding / 2) $padding; 31 | cursor: default; 32 | transition: visibility 0.3s, transform 0.3s, opacity 0.3s; 33 | visibility: hidden; 34 | opacity: 0; 35 | transform: translateY(100px); 36 | -ms-transform: translateY(100px); 37 | z-index: 9999; 38 | &.active { 39 | visibility: visible; 40 | opacity: 1; 41 | transform: translateY(0px); 42 | -ms-transform: translateY(0px); 43 | } 44 | &.top { 45 | top: 0; 46 | bottom: auto; 47 | transform: translateY(-100px); 48 | -ms-transform: translateY(-100px); 49 | &.active { 50 | transform: translateY(0px); 51 | -ms-transform: translateY(0px); 52 | } 53 | } 54 | &.right { 55 | left: auto; 56 | right: 0; 57 | } 58 | .progress-bar { 59 | position: absolute; 60 | left: 0; 61 | bottom: 0; 62 | height: 0.4em; 63 | background: rgba(255,255,255,0.3); 64 | width: 0; 65 | transition: width 3s; 66 | &.active { 67 | width: 100%; 68 | } 69 | } 70 | .message, .action { 71 | vertical-align: middle; 72 | display: table-cell; 73 | } 74 | .action { 75 | float: right; 76 | width: 24px; 77 | height: 24px; 78 | } 79 | .action a:hover { 80 | cursor: pointer; 81 | } 82 | } 83 | 84 | // contexts 85 | .toast { 86 | //default 87 | @include toast-context($toast-background-default, $toast-background-light); 88 | // other contexts 89 | &.toast-info { 90 | @include toast-context($toast-background-info, #fff); 91 | } 92 | &.toast-success { 93 | @include toast-context($toast-background-success, #fff); 94 | } 95 | &.toast-warning { 96 | @include toast-context($toast-background-warning, #fff); 97 | } 98 | &.toast-danger { 99 | @include toast-context($toast-background-danger, #fff); 100 | } 101 | &.toast-dark { 102 | @include toast-context($toast-background-dark, $toast-background-light); 103 | } 104 | &.toast-light { 105 | @include toast-context($toast-background-light, $toast-background-dark); 106 | } 107 | } 108 | 109 | // 110 | .lt-ie10, .ie9, .oldie, .no-csstransitions { 111 | .progress-bar { 112 | display: none; 113 | } 114 | } -------------------------------------------------------------------------------- /Airflix/V1/EpisodeTransformer.php: -------------------------------------------------------------------------------- 1 | $episode->uuid, 38 | 'show_id' => $episode->show_uuid, 39 | 'season_id' => $episode->season_uuid, 40 | 'season' => (int) $episode->season_number, 41 | 'episode' => (int) $episode->episode_number, 42 | 'name' => $episode->name, 43 | 'overview' => $episode->overview, 44 | 'still_url' => $episode->still_url, 45 | 'air_date' => $episode->air_date ? 46 | $episode->air_date->toIso8601String() : null, 47 | 'total_views' => (int) $episode->total_views, 48 | 'has_file' => (bool) $episode->has_file, 49 | ]; 50 | } 51 | 52 | /** 53 | * Include show 54 | * 55 | * @param \Airflix\Episode $episode 56 | * 57 | * @return \League\Fractal\Resource\Item 58 | */ 59 | public function includeShow($episode) 60 | { 61 | $show = $episode->show; 62 | 63 | $transformer = app( 64 | Contracts\ShowTransformer::class 65 | ); 66 | 67 | return $this->item( 68 | $show, 69 | $transformer, 70 | $transformer->resourceType 71 | ); 72 | } 73 | 74 | /** 75 | * Include season 76 | * 77 | * @param \Airflix\Episode $episode 78 | * 79 | * @return \League\Fractal\Resource\Item 80 | */ 81 | public function includeSeason($episode) 82 | { 83 | $season = $episode->season; 84 | 85 | $transformer = app( 86 | Contracts\SeasonTransformer::class 87 | ); 88 | 89 | return $this->item( 90 | $season, 91 | $transformer, 92 | $transformer->resourceType 93 | ); 94 | } 95 | 96 | /** 97 | * Include views 98 | * 99 | * @param \Airflix\Episode $episode 100 | * 101 | * @return \League\Fractal\Resource\Collection 102 | */ 103 | public function includeViews($episode) 104 | { 105 | $views = $episode->monthlyViews(); 106 | 107 | $transformer = app( 108 | Contracts\EpisodeViewMonthlyTransformer::class 109 | ); 110 | 111 | return $this->collection( 112 | $views, 113 | $transformer, 114 | $transformer->resourceType 115 | ); 116 | } 117 | } 118 | --------------------------------------------------------------------------------