Compare commits

...

18 Commits

Author SHA1 Message Date
Krzysztof Płaczek
301244cdb0 marking chapters as complete and toggling value from course list and chapter page. 2023-06-13 16:39:20 +02:00
Krzysztof Płaczek
018597faaa Added command to update all courses and chapters, downloading videos and checking video size is split into two queues, chapters can be mark to sync individually, updated readme, check if subscription is active, playback rate controll, 2023-06-13 13:18:09 +02:00
Krzysztof Płaczek
664d3b4d5b Added controll for video playback speed that is saved to localstorage. 2023-04-27 08:13:31 +02:00
Krzysztof Płaczek
9d2b5414fa Added controll for video playback speed that is saven to localstorage. 2023-04-26 19:24:52 +02:00
Krzysztof Płaczek
cfc84a1d4d Handling the case when there is no video yet, locally or online. 2023-04-08 09:52:44 +02:00
Krzysztof Płaczek
a15c6a1a44 allow sorting by course name and published date, link to manually update course details, 2023-04-07 14:43:51 +02:00
Krzysztof Płaczek
32ead90121 Displaying videos directly from link from symfony cast website without downloading. 2023-03-31 11:25:32 +02:00
Krzysztof Płaczek
dce3cdd929 Updated composer packaged, updated home view table. 2023-03-31 08:44:31 +02:00
Krzysztof Płaczek
5784cf8ed3 Moved video files to public/videos directory, small refactoring. 2023-03-30 12:46:30 +02:00
Krzysztof Płaczek
5902bf7c2b Info and filtering by course tracks, table of content on chapter view with links and previous and next buttons. 2023-01-23 13:23:57 +01:00
Krzysztof Płaczek
d4d8330d83 Changed links to chapters, button to synchronize chapters from courses list, dark mode. 2022-12-07 19:57:57 +01:00
Krzysztof Płaczek
25e28f8193 Added helper file, replaced size file for human version as a helper method, next prev buttons for chapters. 2022-12-07 11:37:50 +01:00
Krzysztof Płaczek
b992d05312 Reading file size 2022-12-05 19:39:12 +01:00
Krzysztof Płaczek
55c104629f Downloading files, calculating video size. 2022-11-17 11:38:20 +01:00
Krzysztof Płaczek
2c62880282 Downloading video to correct directory and displaying it on a chapter page. 2022-11-17 08:24:17 +01:00
Krzysztof Płaczek
21f61b0911 maintanance and downloading files. 2022-11-16 07:18:21 +01:00
Krzysztof Płaczek
086e4b56b5 Updated packages. 2022-09-16 11:57:29 +02:00
Krzysztof Płaczek
c5f4a8ed2d Video size is handled by the queue job. 2022-09-16 10:52:50 +02:00
32 changed files with 2318 additions and 900 deletions

View File

@@ -18,7 +18,7 @@ DB_PASSWORD=
BROADCAST_DRIVER=log BROADCAST_DRIVER=log
CACHE_DRIVER=file CACHE_DRIVER=file
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync QUEUE_CONNECTION=database
SESSION_DRIVER=file SESSION_DRIVER=file
SESSION_LIFETIME=120 SESSION_LIFETIME=120

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ npm-debug.log
yarn-error.log yarn-error.log
/.idea /.idea
/.vscode /.vscode
/public/videos

View File

@@ -1,64 +1,32 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400"></a></p>
<p align="center"> ## Commands
<a href="https://travis-ci.org/laravel/framework"><img src="https://travis-ci.org/laravel/framework.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel Updates information about courses and chapters
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: ```php
php artisan symfonycast:update
```
- [Simple, fast routing engine](https://laravel.com/docs/routing). Dispatch chapters marked as sync offline to queue for download
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications. ```php
php artisan symfonycast:sync
```
Dispatch chapters without file size to queue for update
## Learning Laravel ```php
php artisan symfonycast:video_size
```
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. ## Queues
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains over 2000 video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. Downloading chapters marked as sync offline:
## Laravel Sponsors ```php
php artisan queue:work --queue=downloadVideoFile
```
Get information about file size of chapters:
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the Laravel [Patreon page](https://patreon.com/taylorotwell). ```php
php artisan queue:work --queue=getVideoFileSize
### Premium Partners ```
- **[Vehikl](https://vehikl.com/)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Cubet Techno Labs](https://cubettech.com)**
- **[Cyber-Duck](https://cyber-duck.co.uk)**
- **[Many](https://www.many.co.uk)**
- **[Webdock, Fast VPS Hosting](https://www.webdock.io/en)**
- **[DevSquad](https://devsquad.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel/)**
- **[OP.GG](https://op.gg)**
- **[WebReinvent](https://webreinvent.com/?utm_source=laravel&utm_medium=github&utm_campaign=patreon-sponsors)**
- **[Lendio](https://lendio.com)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Console\Commands;
use App\Jobs\DownloadVideoFile;
use App\Models\Chapter;
use Illuminate\Console\Command;
class SyncFiles extends Command
{
protected $signature = 'symfonycast:sync';
protected $description = 'Downloads files locally for chapters marked as sync offline';
public function handle()
{
$chapters = Chapter::where('sync_offline', 1)->get();
$chapters->each(fn($chapter) => DownloadVideoFile::dispatch($chapter->id));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Console\Commands;
use App\Http\SymfonyCastDl\SymfonyCastDlService;
use App\Jobs\DownloadVideoFile;
use App\Jobs\GetVideoFileSize;
use App\Models\Chapter;
use App\Models\Course;
use Illuminate\Console\Command;
class Update extends Command
{
protected $signature = 'symfonycast:update';
protected $description = 'Updates information about courses and chapters';
public function handle(SymfonyCastDlService $symfonyCastDlService)
{
if (!$symfonyCastDlService->isSubscriptionActive()) {
$this->error('To fully update courses and chapters you need active subscription');
return self::FAILURE;
}
$this->info('Start updating courses');
$courses = $symfonyCastDlService->updateCourses();
$this->output->success(count($courses) . ' courses updated');
$this->output->info('Start updating each course chapters');
$this->withProgressBar($courses, function (Course $course) use ($symfonyCastDlService) {
$symfonyCastDlService->updateChapters($course);
});
$this->output->success('Course chapters updated.');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Console\Commands;
use App\Jobs\DownloadVideoFile;
use App\Jobs\GetVideoFileSize;
use App\Models\Chapter;
use Illuminate\Console\Command;
class UpdateVideoSize extends Command
{
protected $signature = 'symfonycast:video_size';
protected $description = 'Updates information about video size';
public function handle()
{
$chapters = Chapter::where('video_size', 0)->get();
$chapters->each(fn($chapter) => GetVideoFileSize::dispatch($chapter->id));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers\Chapter;
use App\Http\Controllers\Controller;
use App\Models\Chapter;
use App\Models\Course;
use Illuminate\Http\RedirectResponse;
class CompleteToggle extends Controller
{
public function __invoke(Course $course, Chapter $chapter): RedirectResponse
{
$chapter->update(['is_complete' => !$chapter->is_complete]);
return redirect()->back();
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers\Chapter;
use App\Http\Controllers\Controller;
use App\Jobs\DownloadVideoFile;
use App\Models\Chapter;
use App\Models\Course;
use Illuminate\Http\RedirectResponse;
class Sync extends Controller
{
public function __invoke(Course $course, Chapter $chapter): RedirectResponse
{
$chapter->update(['sync_offline' => 1]);
DownloadVideoFile::dispatch($chapter->id);
return redirect()->back();
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers;
use App\Http\SymfonyCastDl\SymfonyCastDlService;
use App\Models\Course;
class ChapterController extends Controller
{
public function __invoke(Course $course, int $chapter, SymfonyCastDlService $symfonyCastDlService)
{
$chapter = $course->chapters->firstWhere('order', $chapter);
$symfonyCastDlService->videoSize($chapter);
if ($chapter->sync_offline) {
$symfonyCastDlService->downloadFile($chapter);
$videoUrl = $chapter->video_url;
} else {
$videoUrl = $symfonyCastDlService->getChapterInfo($chapter);
}
$chapters = $chapter->course->chapters;
$next = $chapters->where('order', '>', $chapter->order)->first();
$prev = $chapters->where('order', '<', $chapter->order)->sortByDesc('order')->first();
return view('chapter.index', compact('chapter', 'chapters', 'prev', 'next', 'videoUrl'));
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers\Course;
use App\Http\Controllers\Controller;
use App\Jobs\DownloadVideoFile;
use App\Models\Course;
use Illuminate\Http\RedirectResponse;
class Sync extends Controller
{
public function __invoke(Course $course): RedirectResponse
{
$course->chapters->each->update(['sync_offline' => 1]);
$course->chapters->each(fn($chapter) => DownloadVideoFile::dispatch($chapter->id));
return redirect()->back();
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers\Course;
use App\Http\Controllers\Controller;
use App\Http\SymfonyCastDl\SymfonyCastDlService;
use App\Models\Course;
use Illuminate\Http\RedirectResponse;
class Update extends Controller
{
public function __invoke(SymfonyCastDlService $symfonyCastDlService, Course $course): RedirectResponse
{
$symfonyCastDlService->updateCourse($course);
return redirect()->back();
}
}

View File

@@ -4,29 +4,15 @@ namespace App\Http\Controllers;
use App\Http\SymfonyCastDl\HtmlParser; use App\Http\SymfonyCastDl\HtmlParser;
use App\Http\SymfonyCastDl\SymfonyCastDlService; use App\Http\SymfonyCastDl\SymfonyCastDlService;
use App\Jobs\DownloadVideoFile;
use App\Models\Chapter; use App\Models\Chapter;
use App\Models\Course; use App\Models\Course;
use Illuminate\View\View; use Illuminate\View\View;
class CourseController extends Controller class CourseController extends Controller
{ {
public function index(Course $course): View public function __invoke(Course $course): View
{ {
return view('course.index', compact('course')); return view('course.index', compact('course'));
} }
public function chapter(Chapter $chapter, HtmlParser $htmlParser)
{
$service = new SymfonyCastDlService($htmlParser);
$service->videoSize($chapter);
dd($chapter->toArray());
}
public function sync(Course $course)
{
$course->chapters->each->update(['sync_offline' => 1]);
return redirect(route('course.index', ['course' => $course]));
}
} }

View File

@@ -5,19 +5,22 @@ namespace App\Http\Controllers;
use App\Http\SymfonyCastDl\HtmlParser; use App\Http\SymfonyCastDl\HtmlParser;
use App\Http\SymfonyCastDl\SymfonyCastDlService; use App\Http\SymfonyCastDl\SymfonyCastDlService;
use App\Models\Course; use App\Models\Course;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class Index extends Controller class Index extends Controller
{ {
public function index() public function index(Request $request, string $track = null)
{ {
$courses = Course::with('chapters')->withCount('chapters')->get(); $courses = Course::where('tracks', 'like', '%' . $track . '%')->with('chapters')->withCount([
'chapters',
'chapters as chapters_to_sync' => function (Builder $query) {
$query->where('sync_offline', 1);
},
]);
$request->whenHas('order', fn($order) => $courses->orderby($order, $request->dir));
$request->whenMissing('order', fn() => $courses->orderby('published_at', 'desc'));
$courses = $courses->get();
return view('index', compact(['courses'])); return view('index', compact(['courses']));
} }
public function download(HtmlParser $htmlParser)
{
$service = new SymfonyCastDlService($htmlParser);
$service->getInfo();
}
} }

View File

@@ -26,8 +26,9 @@ class HtmlParser
$course = Course::firstOrNew(['course_id' => $courseId]); $course = Course::firstOrNew(['course_id' => $courseId]);
$course->name = $courseItem->first('h3')->text(); $course->name = $courseItem->first('h3')->text();
$course->thumbnail = $courseItem->first('img.course-list-item-img')->attr('src'); $course->thumbnail = $courseItem->first('img.course-list-item-img')->attr('src');
$course->link = $courseItem->first('a')->attr('href'); $course->link = last(explode('/', $courseItem->first('a')->attr('href')));
$course->status = $courseItem->attr('data-status'); $course->status = $courseItem->attr('data-status');
$course->tracks = $courseItem->attr('data-tracks');
$course->course_id = $courseItem->attr('data-id'); $course->course_id = $courseItem->attr('data-id');
$course->numberofchapters = $courseItem->attr('data-chapter-count'); $course->numberofchapters = $courseItem->attr('data-chapter-count');
$course->timeswatched = $courseItem->attr('data-times-watched'); $course->timeswatched = $courseItem->attr('data-times-watched');
@@ -48,16 +49,12 @@ class HtmlParser
foreach ($document->find('ul.chapter-list li') as $chapterItem) { foreach ($document->find('ul.chapter-list li') as $chapterItem) {
if ($chapterItem->first('.col')) { if ($chapterItem->first('.col')) {
$chapterId++; $chapterId++;
// if(!$chapterItem->first('.length-styling')){
// dd($chapterItem->html());
// }
$chapter = Chapter::firstOrNew(['course_id' => $courseId, 'order' => $chapterId]); $chapter = Chapter::firstOrNew(['course_id' => $courseId, 'order' => $chapterId]);
$chapter->duration = $chapterItem->first('.length-styling')?->text(); $chapter->duration = $chapterItem->first('.length-styling')?->text();
$chapter->order = $chapterId; $chapter->order = $chapterId;
$chapter->course_id = $courseId; $chapter->course_id = $courseId;
if ($link = trim($chapterItem->first('a')->attr('href'), '#')) { if ($link = trim($chapterItem->first('a')->attr('href'), '#')) {
$chapter->link = config('symfonycast.base_url') . $link; $chapter->link = last(explode('/', $link));;
$chapter->video_link = config('symfonycast.base_url') . $link . '/download/video'; $chapter->video_link = config('symfonycast.base_url') . $link . '/download/video';
} }
$chapter->title = preg_replace('/\v(?:[\v\h]+)/', '', $chapterItem->first('.col')->text()); $chapter->title = preg_replace('/\v(?:[\v\h]+)/', '', $chapterItem->first('.col')->text());
@@ -65,8 +62,17 @@ class HtmlParser
$chapters->add($chapter); $chapters->add($chapter);
} }
} }
return $chapters; return $chapters;
} }
public function getVideoSource(Response $respose): ?string
{
return (new Document($respose->getBody()->getContents()))->first('video source')?->getAttribute('src');
}
public function isSubscriptionActive(Response $respose): bool
{
return (new Document($respose->getBody()->getContents()))->has('.subscription-status .i-active');
}
} }

View File

@@ -2,10 +2,14 @@
namespace App\Http\SymfonyCastDl; namespace App\Http\SymfonyCastDl;
use App\Jobs\GetVideoFileSize;
use App\Models\Chapter; use App\Models\Chapter;
use App\Models\Course; use App\Models\Course;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\TransferStats; use GuzzleHttp\TransferStats;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use Illuminate\Support\Collection;
use JetBrains\PhpStorm\NoReturn;
class SymfonyCastDlService class SymfonyCastDlService
{ {
@@ -17,13 +21,9 @@ class SymfonyCastDlService
'base_uri' => config('symfonycast.base_url'), 'base_uri' => config('symfonycast.base_url'),
'cookies' => true 'cookies' => true
]); ]);
$response = $this->client->get('login'); $response = $this->client->get('login');
$token = $htmlParser->getCsrfToken($response); $token = $htmlParser->getCsrfToken($response);
$this->client->post('login', [
$response = $this->client->post('login', [
'form_params' => [ 'form_params' => [
'email' => config('symfonycast.login'), 'email' => config('symfonycast.login'),
'password' => config('symfonycast.password'), 'password' => config('symfonycast.password'),
@@ -35,34 +35,71 @@ class SymfonyCastDlService
]); ]);
} }
public function getInfo() /**
* @return Collection
* @throws GuzzleException
*/
public function updateCourses(): Collection
{ {
$coursePage = $this->client->get('courses/filtering'); $coursePage = $this->client->get('courses/filtering');
$courses = $this->htmlParser->getCourses($coursePage); $courses = $this->htmlParser->getCourses($coursePage);
$courses->each(fn($course) => $course->save()); return $courses->each->save();
// $singleCoursePage = $this->client->get($courses[3]->link);
/** @var Course $course */
foreach ($courses as $course) {
$singleCoursePage = $this->client->get($course->link);
$chapters = $this->htmlParser->getCourseDetails($singleCoursePage, $course->id);
// $chapters->each(fn($chapter) => $this->videoSize($chapter)->save());
$chapters->each(fn($chapter) => $chapter->save());
} }
/**
* @param Course $course
* @return Collection
* @throws GuzzleException
*/
public function updateChapters(Course $course): Collection
{
$singleCoursePage = $this->client->get('/screencast/' . $course->link);
$chapters = $this->htmlParser->getCourseDetails($singleCoursePage, $course->id);
$chapters->each->save();
$chapters->each(fn($chapter) => GetVideoFileSize::dispatch($chapter->id));
return $chapters;
}
public function isSubscriptionActive(): bool
{
return $this->htmlParser->isSubscriptionActive($this->client->get('/profile/show'));
}
public function getChapterInfo(Chapter $chapter): ?string
{
$course = $chapter->course;
$chapterPage = $this->client->get('/screencast/' . $course->link . DIRECTORY_SEPARATOR . $chapter->link);
return $this->htmlParser->getVideoSource($chapterPage);
} }
public function videoSize(Chapter $chapter): Chapter public function videoSize(Chapter $chapter): Chapter
{ {
try { if (!$chapter->video_size && $chapter->video_link) {
if (!$chapter->video_size) {
$response = $this->client->head($chapter->video_link); $response = $this->client->head($chapter->video_link);
if ($response->hasHeader('Content-Length')) { if ($response->hasHeader('Content-Length')) {
$chapter->video_size = $response->getHeader('Content-Length')[0]; $chapter->video_size = (int)$response->getHeader('Content-Length')[0];
}
$chapter->save(); $chapter->save();
} }
} catch (\Exception $exception) {
} }
return $chapter; return $chapter;
} }
public function downloadFile(Chapter $chapter): void
{
if (is_null($chapter->video_link)) {
return;
}
if (!is_dir($chapter->directory_path)) {
mkdir(directory: $chapter->directory_path, recursive: true);
}
if (!$chapter->is_video_file) {
$this->client->request(
'GET',
$chapter->video_link,
['sink' => $chapter->video_path],
);
}
$chapter->video_size = filesize($chapter->video_path);
$chapter->save();
}
} }

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Jobs;
use App\Http\SymfonyCastDl\SymfonyCastDlService;
use App\Models\Chapter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class DownloadVideoFile implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public const NAME = 'downloadVideoFile';
public function __construct(private int $chapterId)
{
$this->onQueue(self::NAME);
}
public function uniqueId(): string
{
return $this->chapterId;
}
public function handle(SymfonyCastDlService $symfonyCastDlService)
{
$symfonyCastDlService->downloadFile(Chapter::find($this->chapterId));
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Jobs;
use App\Http\SymfonyCastDl\SymfonyCastDlService;
use App\Models\Chapter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class GetVideoFileSize implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public const NAME = 'getVideoFileSize';
public function __construct(private int $chapterId)
{
$this->onQueue(self::NAME);
}
public function uniqueId(): string
{
return $this->chapterId;
}
public function handle(SymfonyCastDlService $symfonyCastDlService)
{
$symfonyCastDlService->videoSize(Chapter::find($this->chapterId));
}
}

View File

@@ -2,11 +2,13 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
* @property int $id
* @property int $order * @property int $order
* @property string $link * @property string $link
* @property string $video_link * @property string $video_link
@@ -15,6 +17,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string $duration * @property string $duration
* @property integer $course_id * @property integer $course_id
* @property bool $sync_offline * @property bool $sync_offline
* @property bool $is_complete
* @property string $video_path
* @property string $directory_path
* @property Course $course
*/ */
class Chapter extends Model class Chapter extends Model
{ {
@@ -29,10 +35,53 @@ class Chapter extends Model
'duration', 'duration',
'course_id', 'course_id',
'sync_offline', 'sync_offline',
'is_complete',
]; ];
protected $appends = ['video_path', 'directory_path', 'video_url'];
public function course(): BelongsTo public function course(): BelongsTo
{ {
return $this->belongsTo(Course::class); return $this->belongsTo(Course::class);
} }
protected function videoUrl(): Attribute
{
return Attribute::make(
get: fn() => url(
'videos' . DIRECTORY_SEPARATOR . $this->course_id . DIRECTORY_SEPARATOR . $this->order . '.mp4'
)
);
}
protected function videoPath(): Attribute
{
return Attribute::make(
get: fn() => $this->directory_path . '/' . $this->order . '.mp4'
);
}
protected function directoryPath(): Attribute
{
return Attribute::make(
get: fn() => public_path('videos' . DIRECTORY_SEPARATOR . $this->course_id)
);
}
protected function isVideoFile(): Attribute
{
return Attribute::make(
get: fn() => is_file($this->video_path)
);
}
public function videoSizeHuman(): Attribute
{
return Attribute::make(
get: function () {
return formatFileSize($this->video_size);
},
);
}
} }

View File

@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Carbon\Traits\Date; use Carbon\Traits\Date;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -13,9 +14,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property string $thumbnail * @property string $thumbnail
* @property string $link * @property string $link
* @property string $status * @property string $status
* @property string $tracks
* @property integer $numberofchapters * @property integer $numberofchapters
* @property integer $timeswatched * @property integer $timeswatched
* @property Date $published_at * @property Date $published_at
* @property Chapter[] $chapters
*/ */
class Course extends Model class Course extends Model
{ {
@@ -26,11 +29,13 @@ class Course extends Model
'thumbnail', 'thumbnail',
'link', 'link',
'status', 'status',
'tracks',
'numberofchapters', 'numberofchapters',
'timeswatched', 'timeswatched',
'published_at', 'published_at',
]; ];
protected $appends = ['total_size', 'total_size_human'];
protected $dates = [ protected $dates = [
'published_at' 'published_at'
]; ];
@@ -39,4 +44,25 @@ class Course extends Model
{ {
return $this->hasMany(Chapter::class); return $this->hasMany(Chapter::class);
} }
protected function totalSize(): Attribute
{
return Attribute::make(
get: fn() => $this->chapters->sum('video_size'),
);
}
protected function tracks(): Attribute
{
return Attribute::make(
get: fn(string $tracks) => empty($tracks) ? [] : explode(',', $tracks),
);
}
protected function totalSizeHuman(): Attribute
{
return Attribute::make(
get: fn() => formatFileSize($this->total_size),
);
}
} }

View File

@@ -2,6 +2,8 @@
namespace App\Providers; namespace App\Providers;
use App\Http\SymfonyCastDl\HtmlParser;
use App\Http\SymfonyCastDl\SymfonyCastDlService;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -13,7 +15,10 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register() public function register()
{ {
// $this->app->singleton(SymfonyCastDlService::class, function ($app) {
$htmlparser = new HtmlParser();
return new SymfonyCastDlService($htmlparser);
});
} }
/** /**

22
app/helpers.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
if (!function_exists('formatFileSize')) {
function formatFileSize($fileSizeInBytes): string
{
$base = log($fileSizeInBytes) / log(1024);
$suffix = ["", "k", "M", "G", "T"][floor($base)];
$size = round(pow(1024, $base - floor($base)), 2);
return !is_nan($size) ? $size . $suffix : '0B';
}
}
if (!function_exists('folderSize')) {
function folderSize($dir): int
{
$size = 0;
foreach (glob(rtrim($dir, '/') . '/*', GLOB_NOSORT) as $each) {
$size += is_file($each) ? filesize($each) : folderSize($each);
}
return $size;
}
}

View File

@@ -2,10 +2,14 @@
"name": "laravel/laravel", "name": "laravel/laravel",
"type": "project", "type": "project",
"description": "The Laravel Framework.", "description": "The Laravel Framework.",
"keywords": ["framework", "laravel"], "keywords": [
"framework",
"laravel"
],
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.0.2", "php": "^8.0.2",
"doctrine/dbal": "^3.5",
"guzzlehttp/guzzle": "^7.2", "guzzlehttp/guzzle": "^7.2",
"imangazaliev/didom": "^2.0", "imangazaliev/didom": "^2.0",
"laravel/framework": "^9.19", "laravel/framework": "^9.19",
@@ -19,6 +23,7 @@
"laravel/sail": "^1.0.1", "laravel/sail": "^1.0.1",
"mockery/mockery": "^1.4.4", "mockery/mockery": "^1.4.4",
"nunomaduro/collision": "^6.1", "nunomaduro/collision": "^6.1",
"phpspec/phpspec": "^7.4",
"phpunit/phpunit": "^9.5.10", "phpunit/phpunit": "^9.5.10",
"spatie/laravel-ignition": "^1.0" "spatie/laravel-ignition": "^1.0"
}, },
@@ -27,7 +32,10 @@
"App\\": "app/", "App\\": "app/",
"Database\\Factories\\": "database/factories/", "Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/" "Database\\Seeders\\": "database/seeders/"
} },
"files": [
"app/helpers.php"
]
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "psr-4": {

2317
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('jobs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('jobs');
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('failed_jobs');
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('courses', function (Blueprint $table) {
$table->text('tracks')->after('status')->nullable();
});
}
public function down(): void
{
Schema::table('courses', function (Blueprint $table) {
$table->removeColumn('tracks');
});
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('chapters', function (Blueprint $table) {
$table->boolean('is_complete')->default(0);
});
}
public function down(): void
{
Schema::table('chapters', function (Blueprint $table) {
$table->removeColumn('is_complete');
});
}
};

View File

@@ -0,0 +1,84 @@
<x-layout>
<h1>
<a href="/" class="text-decoration-none">List</a> &raquo; <a class="text-decoration-none"
href="{{ route('course.index', ['course' => $chapter->course_id]) }}">{{ $chapter->course->name }}</a>
<br>
<small class="text-muted">{{ $chapter->title }}</small>
</h1>
<div>
<div class="container-fluid">
<div class="row">
<div class="col">
@if($prev)
<div class="float-start m-2"><a
href="{{ route('course.chapter', ['course' => $chapter->course_id, 'chapter' => $prev->order]) }}"
class="btn btn-primary">Prev</a>
</div>
@endif
</div>
<div class="col">
@if($videoUrl)
<div class="d-flex justify-content-center flex-column">
<div class="p-2">
<video class="mx-auto" width="500" controls>
<source src="{{ $videoUrl }}" type="video/mp4"/>
</video>
</div>
<div class="p-2">
<input type="range" id="playback_range" min="0.5" max="4" value="1" step="0.25">
<label for="cowbell">playback speed</label>
<span id="playback"> </span>
</div>
</div>
@else
no video
@endif
<div>
<table class="table table-sm">
@foreach($chapters as $courseChapter)
<tr class="@if($courseChapter->id === $chapter->id)table-dark @endif">
<td><a href="{{ route('course.chapter.complete_toggle', ['course' => $courseChapter->course->id, 'chapter' => $courseChapter->id]) }}" class="text-decoration-none"><span class="icon-check {{ $courseChapter->is_complete?'text-info':'text-dark' }}"></span></a></td>
<td>
<a href="{{ route('course.chapter', ['course' => $courseChapter->course_id, 'chapter' => $courseChapter->order ]) }}"
class="text-decoration-none">{{ $courseChapter->title }}</a>
</td>
<td class="text-end">{{ $courseChapter->duration }}</td>
</tr>
@endforeach
</table>
</div>
</div>
<div class="col">
@if($next)
<div class="float-end m-2"><a
href="{{ route('course.chapter', ['course' => $chapter->course_id, 'chapter' => $next->order]) }}"
class="btn btn-primary">Next</a>
</div>
@endif
</div>
</div>
</div>
</div>
<script>
const value = document.querySelector("#playback")
const input = document.querySelector("#playback_range")
document.querySelector('body').onload = function () {
const playbackRate = localStorage['playback'] || 1;
document.querySelector('video').playbackRate = playbackRate;
document.querySelector('#playback_range').value = playbackRate;
value.textContent = 'x' + playbackRate;
};
value.textContent = 'x' + input.value
input.addEventListener("input", (event) => {
localStorage['playback'] = event.target.value;
value.textContent = 'x' + event.target.value;
document.querySelector('video').playbackRate = event.target.value;
})
</script>
</x-layout>

View File

@@ -2,9 +2,11 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>{{ $title ?? 'Symfonycast.local' }}</title> <title>{{ $title ?? 'Symfonycast.local' }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous"> crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightfall.min.css"
rel="stylesheet" media="(prefers-color-scheme: dark)">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/simple-line-icons/2.5.5/css/simple-line-icons.min.css">
</head> </head>
<body> <body>
{{ $slot }} {{ $slot }}

View File

@@ -1,27 +1,45 @@
<x-layout> <x-layout>
<h1> <h1>
{{ $course->name }} <a href="/" class="text-decoration-none">List</a> &raquo; {{ $course->name }}
</h1> </h1>
<a href="{{ 'https://symfonycasts.com/screencast/'.$course->link }}">Visit course page</a>
<a href="{{ route('course.sync', ['course' => $course->id]) }}">Sync all chapters offline</a> <a href="{{ route('course.sync', ['course' => $course->id]) }}">Sync all chapters offline</a>
<a href="{{ route('course.update', ['course' => $course->id]) }}">Update course</a>
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>Title</th> <th>Title</th>
<th>Duration</th> <th>Duration</th>
<th>Sync offline</th> <th>Sync offline</th>
<th>Synced offline</th>
<th>File size</th>
<th></th>
</tr> </tr>
</thead> </thead>
@foreach($course->chapters()->get() as $chapter) @foreach($course->chapters()->get() as $chapter)
<tr> <tr>
<td><a href="{{ route('course.chapter', ['chapter' => $chapter->id]) }}">{{ $chapter->title }}</a></td> <td><a href="{{ route('course.chapter', ['course' => $course->id, 'chapter' => $chapter->order]) }}"
<td>{{ $chapter->sync_offline?'Yes':'No' }}</td> class="text-decoration-none">{{ $chapter->title }}</a></td>
<td><a href="{{ route('course.chapter.complete_toggle', ['course' => $course->id, 'chapter' => $chapter->id]) }}" class="text-decoration-none"><span class="icon-check {{ $chapter->is_complete?'text-info':'text-dark' }}"></span></a></td>
<td>{{ $chapter->duration }}</td> <td>{{ $chapter->duration }}</td>
<td>{{ $chapter->sync_offline?'Yes':'no' }}</td>
<td>{{ $chapter->is_video_file?'Yes':'-' }}</td>
<td>{{ $chapter->video_size_human }}</td>
<td class="text-end">
@if(!$chapter->sync_offline)
<a
href="{{ route('course.chapter.sync', ['course' => $chapter->course->id,'chapter' => $chapter->id]) }}">sync</a>
@endif
</td>
</tr> </tr>
@endforeach @endforeach
<tr> <tr>
<td></td> <td></td>
<td>Total: {{ $course->course_duration }}</td> <td>Total: {{ $course->course_duration }}</td>
<td></td>
<td></td>
<td>Size: {{ $course->total_size_human }}</td>
</tr> </tr>
</table> </table>
</x-layout> </x-layout>

View File

@@ -1,24 +1,54 @@
<x-layout> <x-layout>
<h1><a class="text-decoration-none" href="{{route('index')}}">List of Courses</a> <small
class="text-muted">{{ request()->has('track')?:request()->track }}</small></h1>
<table class="table table-sm"> <table class="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Id</th>
<th></th> <th></th>
<th>Name</th> <th>
<th>status</th>
<th>Chapters</th> <a href="{{ route('index', ['order' => 'name', 'dir' => request()->whenHas('dir', fn($dir) => ['asc'=>'desc', 'desc'=>'asc'][$dir], fn() => 'desc')]) }}">Name</a>
<th>Published at</th> </th>
<th>Tracks</th>
<th>Sync</th>
<th>Estimate file size</th>
<th>Actual size</th>
</tr> </tr>
</thead> </thead>
@foreach($courses as $course) @foreach($courses as $course)
<tr> <tr>
<td class="align-middle">{{ $course->id }}</td> <td><img src="{{ $course->thumbnail }}" alt="{{ $course->name }}" class="img-thumbnail"
<td><img src="{{ $course->thumbnail }}" alt="{{ $course->name }}" class="img-thumbnail" style="width: 70px;"></td> style="width: 70px;"></td>
<td class="align-middle"><a href="{{ route('course.index', ['course' => $course->id]) }}">{{ $course->name }}</a></td> <td class="align-middle">
<td class="align-middle">{{ $course->status }}</td> <a class="text-decoration-none"
<td class="align-middle">{{ $course->chapters_count }} / {{ $course->numberofchapters }}</td> href="{{ route('course.index', ['course' => $course->id]) }}">{{ $course->name }}</a>
<td class="align-middle"><abbr title="{{ $course->published_at?->format('Y-m-d') }}">{{ $course->published_at?->diffForHumans() }}</abbr></td> <p class="">
course status: <span class="fw-lighter">{{ $course->status }}</span>
published: <span class="fw-lighter"><a
href="?order=published_at&dir={{ request()->has('dir')? ['asc'=>'desc', 'desc'=>'asc'][request()->dir]: 'desc'}}"><abbr
title="{{ $course->published_at?->format('Y-m-d') }}">{{ $course->published_at?->diffForHumans() }}</abbr></a></span>
synced chapters: <span
class="fw-lighter">{{ $course->chapters_to_sync }} of {{ $course->numberofchapters }}</span>
</p>
</td>
<td class="align-middle">
@foreach($course->tracks as $track)
<a href="{{ route('index.track', ['track' => $track]) }}"
class="badge rounded-pill bg-secondary text-decoration-none">{{ $track }}</a>
@endforeach
</td>
<td class="align-middle"><a class="btn btn-outline-primary"
href="{{ route('course.sync', ['course' => $course->id]) }}">Sync all
chapters offline</a></td>
<td class="align-middle text-end pe-4">{{ $course->total_size_human }}</td>
<td class="align-middle text-end pe-4">{{formatFileSize(folderSize(public_path('videos'.DIRECTORY_SEPARATOR.$course->id)))}}</td>
</tr> </tr>
@endforeach @endforeach
<tr>
<td colspan="4"></td>
<td class="align-middle text-end pe-4">{{ formatFileSize($courses->sum('total_size')) }}</td>
<td class="align-middle text-end pe-4">{{formatFileSize(folderSize(public_path()))}}</td>
</tr>
</table> </table>
</x-layout> </x-layout>

View File

@@ -1,22 +1,22 @@
<?php <?php
use App\Http\Controllers\ChapterController;
use App\Http\Controllers\Course;
use App\Http\Controllers\Chapter;
use App\Http\Controllers\CourseController;
use App\Http\Controllers\Index;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
/* Route::get('/download', [Index::class, 'download']);
|-------------------------------------------------------------------------- Route::get('/', [Index::class, 'index'])->name('index');
| Web Routes Route::get('/track/{track}', [Index::class, 'index'])->name('index.track');
|-------------------------------------------------------------------------- Route::prefix('course')->group(function () {
| Route::get('/{course}', CourseController::class)->name('course.index');
| Here is where you can register web routes for your application. These Route::get('/{course}/sync', Course\Sync::class)->name('course.sync');
| routes are loaded by the RouteServiceProvider within a group which Route::get('/{course}/update', Course\Update::class)->name('course.update');
| contains the "web" middleware group. Now create something great! Route::get('/{course}/chapter/{chapter}', ChapterController::class)->name('course.chapter');
| Route::get('/{course}/chapter/{chapter}/sync', Chapter\Sync::class)->name('course.chapter.sync');
*/ Route::get('/{course}/chapter/{chapter}/completetoggle', Chapter\CompleteToggle::class)->name('course.chapter.complete_toggle');
});
Route::get('/download', [\App\Http\Controllers\Index::class, 'download']);
Route::get('/', [\App\Http\Controllers\Index::class, 'index']);
Route::get('/course/{course}', [\App\Http\Controllers\CourseController::class, 'index'])->name('course.index');
Route::get('/course/{course}/sync', [\App\Http\Controllers\CourseController::class, 'sync'])->name('course.sync');
Route::get('/chapter/{chapter}', [\App\Http\Controllers\CourseController::class, 'chapter'])->name('course.chapter');