| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,176 +1,165 @@ | ||
| { | ||
| "$schema": "./node_modules/@angular/cli/lib/config/schema.json", | ||
| "cli": { | ||
| "schematicCollections": ["@angular-eslint/schematics"], | ||
| "analytics": false | ||
| }, | ||
| "version": 1, | ||
| "newProjectRoot": "projects", | ||
| "projects": { | ||
| "iptvnator": { | ||
| "root": "", | ||
| "sourceRoot": "src", | ||
| "projectType": "application", | ||
| "schematics": { | ||
| "@schematics/angular:application": { | ||
| "strict": true, | ||
| "style": "scss" | ||
| } | ||
| }, | ||
| "architect": { | ||
| "build": { | ||
| "builder": "@angular-builders/custom-webpack:browser", | ||
| "options": { | ||
| "outputPath": "dist", | ||
| "index": "src/index.html", | ||
| "main": "src/main.ts", | ||
| "tsConfig": "src/tsconfig.app.json", | ||
| "polyfills": "src/polyfills.ts", | ||
| "assets": [ | ||
| "src/favicon.ico", | ||
| "src/assets", | ||
| "src/manifest.webmanifest" | ||
| ], | ||
| "styles": ["src/styles.scss"], | ||
| "scripts": [], | ||
| "customWebpackConfig": { | ||
| "path": "./angular.webpack.js", | ||
| "replaceDuplicatePlugins": true | ||
| }, | ||
| "serviceWorker": true, | ||
| "ngswConfigPath": "ngsw-config.json", | ||
| "webWorkerTsConfig": "tsconfig.worker.json" | ||
| }, | ||
| "configurations": { | ||
| "dev": { | ||
| "optimization": false, | ||
| "outputHashing": "none", | ||
| "sourceMap": true, | ||
| "namedChunks": false, | ||
| "aot": false, | ||
| "extractLicenses": true, | ||
| "vendorChunk": false, | ||
| "buildOptimizer": false, | ||
| "fileReplacements": [ | ||
| { | ||
| "replace": "src/environments/environment.ts", | ||
| "with": "src/environments/environment.dev.ts" | ||
| } | ||
| ] | ||
| }, | ||
| "web": { | ||
| "optimization": true, | ||
| "outputHashing": "all", | ||
| "sourceMap": false, | ||
| "namedChunks": false, | ||
| "aot": true, | ||
| "extractLicenses": true, | ||
| "vendorChunk": false, | ||
| "buildOptimizer": true, | ||
| "fileReplacements": [ | ||
| { | ||
| "replace": "src/environments/environment.ts", | ||
| "with": "src/environments/environment.web.ts" | ||
| } | ||
| ] | ||
| }, | ||
| "production": { | ||
| "optimization": true, | ||
| "outputHashing": "all", | ||
| "sourceMap": false, | ||
| "namedChunks": false, | ||
| "aot": true, | ||
| "extractLicenses": true, | ||
| "vendorChunk": false, | ||
| "buildOptimizer": true, | ||
| "fileReplacements": [ | ||
| { | ||
| "replace": "src/environments/environment.ts", | ||
| "with": "src/environments/environment.prod.ts" | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| }, | ||
| "serve": { | ||
| "builder": "@angular-builders/custom-webpack:dev-server", | ||
| "options": { | ||
| "browserTarget": "iptvnator:build" | ||
| }, | ||
| "configurations": { | ||
| "dev": { | ||
| "browserTarget": "iptvnator:build:dev" | ||
| }, | ||
| "web": { | ||
| "browserTarget": "iptvnator:build:web" | ||
| }, | ||
| "production": { | ||
| "browserTarget": "iptvnator:build:production" | ||
| } | ||
| } | ||
| }, | ||
| "extract-i18n": { | ||
| "builder": "@angular-devkit/build-angular:extract-i18n", | ||
| "options": { | ||
| "browserTarget": "iptvnator:build" | ||
| } | ||
| }, | ||
| "test": { | ||
| "builder": "@angular-builders/custom-webpack:karma", | ||
| "options": { | ||
| "main": "src/test.ts", | ||
| "polyfills": "src/polyfills-test.ts", | ||
| "tsConfig": "src/tsconfig.spec.json", | ||
| "karmaConfig": "src/karma.conf.js", | ||
| "scripts": [], | ||
| "styles": ["src/styles.scss"], | ||
| "assets": ["src/assets"], | ||
| "customWebpackConfig": { | ||
| "path": "./angular.webpack.js", | ||
| "replaceDuplicatePlugins": true | ||
| }, | ||
| "webWorkerTsConfig": "tsconfig.worker.json" | ||
| } | ||
| }, | ||
| "lint": { | ||
| "builder": "@angular-eslint/builder:lint", | ||
| "options": { | ||
| "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"] | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| "iptvnator-e2e": { | ||
| "root": "e2e", | ||
| "projectType": "application", | ||
| "architect": { | ||
| "lint": { | ||
| "builder": "@angular-eslint/builder:lint", | ||
| "options": { | ||
| "lintFilePatterns": ["e2e/**/*.ts"] | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| "schematics": { | ||
| "@schematics/angular:component": { | ||
| "prefix": "app", | ||
| "style": "scss" | ||
| }, | ||
| "@schematics/angular:directive": { | ||
| "prefix": "app" | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| # Stage 1 - build environment | ||
| FROM node:18-alpine AS build | ||
|
|
||
| RUN apk add --no-cache python3 make g++ | ||
|
|
||
| # Create app directory | ||
| WORKDIR /usr/src/app | ||
|
|
||
| # Swtich to node user | ||
| #RUN chown node:node ./ | ||
| #USER node | ||
|
|
||
| COPY package*.json ./ | ||
|
|
||
| # Install app dependencies | ||
| RUN npm ci && npm cache clean --force | ||
|
|
||
| # Copy all required files | ||
| COPY . . | ||
|
|
||
| # Build the application | ||
| RUN npm run build:web | ||
|
|
||
| # Stage 2 - the production environment | ||
| FROM nginx:stable-alpine | ||
|
|
||
| # Copy artifacts and nignx.conf | ||
| COPY --from=build /usr/src/app/dist/ /usr/share/nginx/html | ||
| COPY --from=build /usr/src/app/docker/nginx.conf /etc/nginx/conf.d/default.conf | ||
|
|
||
| CMD sed -i "s#http://localhost:3333#$BACKEND_URL#g" /usr/share/nginx/html/main.*.js && nginx -g 'daemon off;' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| # Self-hosted version of IPTVnator | ||
|
|
||
| You can deploy and run the PWA version of IPTVnator on your own machine with `docker-compose` using the following command: | ||
|
|
||
| $ cd docker | ||
| $ docker-compose up -d | ||
|
|
||
| This command will launch the frontend and backend applications. By default, the application will be available at: http://localhost:4333/. The ports can be configured in the `docker-compose.yml` file. | ||
|
|
||
| ## Build frontend | ||
|
|
||
| $ docker build -t 4gray/iptvnator -f docker/Dockerfile . | ||
|
|
||
| ## Build backend | ||
|
|
||
| You can find the backend app with all instructions in a separate GitHub repository - https://github.com/4gray/iptvnator-backend |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| version: '3' | ||
|
|
||
| services: | ||
| backend: | ||
| image: 4gray/iptvnator-backend:latest | ||
| ports: | ||
| - "7333:3000" | ||
| environment: | ||
| - CLIENT_URL=http://localhost:4333 #this one should match with the address and port in frontend CLIENT_URL env | ||
|
|
||
| frontend: | ||
| image: 4gray/iptvnator:latest | ||
| ports: | ||
| - "4333:80" | ||
| environment: | ||
| - BACKEND_URL=http://192.168.172.67:7333 # this one should match with the address of the backend service | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| server { | ||
| listen 80; | ||
|
|
||
| location / { | ||
| root /usr/share/nginx/html; | ||
| index index.html index.htm; | ||
| try_files $uri $uri/ /index.html =404; | ||
| } | ||
|
|
||
| include /etc/nginx/extra-conf.d/*.conf; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { | ||
| BrowserContext, | ||
| ElectronApplication, | ||
| Page, | ||
| _electron as electron, | ||
| } from 'playwright'; | ||
| const PATH = require('path'); | ||
| const fs = require('fs'); | ||
|
|
||
| let app: ElectronApplication; | ||
| let page: Page; | ||
| let context: BrowserContext; | ||
|
|
||
| test.beforeAll(async () => { | ||
| app = await electron.launch({ | ||
| args: [PATH.join(__dirname, '../electron/main.js')], | ||
| env: { | ||
| e2e: 'true', | ||
| }, | ||
| }); | ||
|
|
||
| context = app.context(); | ||
| await context.tracing.start({ screenshots: true, snapshots: true }); | ||
|
|
||
| page = await app.firstWindow(); | ||
| const isMainWindow = (await page.title()) === 'IPTVnator'; | ||
| if (!isMainWindow) { | ||
| page = app.windows()[1]; | ||
| } | ||
| await page.waitForLoadState('domcontentloaded'); | ||
| }); | ||
|
|
||
| test.describe('Check Home Page', () => { | ||
| test('Launch electron app', async () => { | ||
| const windowState = await app.evaluate((electronProcess) => { | ||
| let mainWindow = electronProcess.BrowserWindow.getAllWindows()[0]; | ||
| const isMainWindow = mainWindow.title === 'IPTVnator'; | ||
| if (!isMainWindow) { | ||
| mainWindow = electronProcess.BrowserWindow.getAllWindows()[1]; | ||
| } | ||
|
|
||
| return { | ||
| isVisible: mainWindow.isVisible(), | ||
| isDevToolsOpened: mainWindow.webContents.isDevToolsOpened(), | ||
| isCrashed: mainWindow.webContents.isCrashed(), | ||
| }; | ||
| }); | ||
|
|
||
| expect(windowState.isVisible).toBeTruthy(); | ||
| expect(windowState.isDevToolsOpened).toBeFalsy(); | ||
| expect(windowState.isCrashed).toBeFalsy(); | ||
| expect(app.windows()).toHaveLength(2); | ||
| }); | ||
|
|
||
| // eslint-disable-next-line no-empty-pattern | ||
| test('Check title of the application', async ({}, testInfo) => { | ||
| const title = await page.title(); | ||
| const screenshot = await page.screenshot({ | ||
| path: `./e2e/screenshots/home/${testInfo.title}.png`, | ||
| }); | ||
| await testInfo.attach('screenshot', { | ||
| body: screenshot, | ||
| contentType: 'image/png', | ||
| }); | ||
| expect(title).toBe('IPTVnator'); | ||
| }); | ||
| }); | ||
|
|
||
| test.describe('Upload playlists', () => { | ||
| test('should upload m3u playlist via file upload', async () => { | ||
| await page.click('"Add via file upload"'); | ||
| await page.setInputFiles( | ||
| 'input[type="file"]', | ||
| './e2e/fixtures/test.m3u' | ||
| ); | ||
| await expect(page.getByTestId('channel-item')).toHaveCount(4); | ||
| }); | ||
| }); | ||
|
|
||
| test.afterAll(async () => { | ||
| deleteDbFile(); | ||
| await page.close(); | ||
| await app.close(); | ||
| }); | ||
|
|
||
| function deleteDbFile() { | ||
| const pathToFile = './e2e/db/data.db'; | ||
|
|
||
| try { | ||
| fs.unlink(pathToFile, () => { | ||
| console.log('db file was deleted'); | ||
| }); | ||
| } catch (error) { | ||
| console.log('db file not found'); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| #EXTM3U url-tvg="http://xml-url/path/full.xml.gz" | ||
| #EXTINF:0 tvg-id="1" tvg-logo="http://channel.icons.url/img/1.png" catchup="shift" catchup-days="3" timeshift="3" group-title="News", Channel 1 | ||
| #EXTGRP:News | ||
| https://example.channels/path-to-file/1.m3u8 | ||
| #EXTINF:0 tvg-id="2" tvg-logo="http://channel.icons.url/img/2.png" catchup="shift" catchup-days="3" timeshift="3" group-title="News", Positive News TV | ||
| #EXTGRP:News | ||
| https://example.channels/path-to-file/2.m3u8 | ||
| #EXTINF:0 tvg-id="3" tvg-logo="http://channel.icons.url/img/3.png" catchup="shift" catchup-days="3" timeshift="3" group-title="Sport", Sport TVX | ||
| #EXTGRP:Sport | ||
| https://example.channels/path-to-file/3.m3u8 | ||
| #EXTINF:0 tvg-id="4" tvg-logo="http://channel.icons.url/img/4.png" catchup="shift" catchup-days="3" timeshift="3" group-title="Kids", HappyKids TV |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { | ||
| BrowserContext, | ||
| ElectronApplication, | ||
| Page, | ||
| _electron as electron, | ||
| } from 'playwright'; | ||
| const PATH = require('path'); | ||
|
|
||
| let app: ElectronApplication; | ||
| let page: Page; | ||
| let context: BrowserContext; | ||
|
|
||
| test.beforeAll(async () => { | ||
| app = await electron.launch({ | ||
| args: [PATH.join(__dirname, '../electron/main.js')], | ||
| env: { | ||
| e2e: 'true', | ||
| }, | ||
| }); | ||
|
|
||
| context = app.context(); | ||
| await context.tracing.start({ screenshots: true, snapshots: true }); | ||
|
|
||
| page = await app.firstWindow(); | ||
| const isMainWindow = (await page.title()) === 'IPTVnator'; | ||
| if (!isMainWindow) { | ||
| page = app.windows()[1]; | ||
| } | ||
| await page.waitForLoadState('domcontentloaded'); | ||
| }); | ||
|
|
||
| test.describe('Settings', () => { | ||
| test('Check settings page', async () => { | ||
| await page.getByTestId('open-settings').click(); | ||
| await expect(page.getByTestId('settings-container')).toBeVisible(); | ||
| await page.getByTestId('back-to-home').click(); | ||
| }); | ||
|
|
||
| test('Change video player', async () => { | ||
| await page.getByTestId('open-settings').click(); | ||
|
|
||
| await expect(page.locator('text="VideoJs Player"')).toBeVisible(); | ||
| await page.getByTestId('select-video-player').click(); | ||
| await page.getByTestId('html5').click(); | ||
|
|
||
| await page.getByTestId('save-settings').click(); | ||
| await page.getByTestId('back-to-home').click(); | ||
| await page.getByTestId('open-settings').click(); | ||
|
|
||
| await expect(page.locator('text="HTML5 Video Player"')).toBeVisible(); | ||
| }); | ||
|
|
||
| test('Change app theme', async () => { | ||
| await page.getByTestId('open-settings').click(); | ||
|
|
||
| await expect(page.locator('text="Light theme"')).toBeVisible(); | ||
| await page.getByTestId('select-theme').click(); | ||
| await page.getByTestId('DARK_THEME').click(); | ||
|
|
||
| await page.getByTestId('save-settings').click(); | ||
| await page.getByTestId('back-to-home').click(); | ||
| await page.getByTestId('open-settings').click(); | ||
|
|
||
| await expect(page.locator('text="Dark theme"')).toBeVisible(); | ||
| }); | ||
|
|
||
| test('Change app language', async () => { | ||
| await page.getByTestId('open-settings').click(); | ||
|
|
||
| await expect(page.locator('text="English"')).toBeVisible(); | ||
| await page.getByTestId('select-language').click(); | ||
| await page.getByTestId('de').click(); | ||
|
|
||
| await page.getByTestId('save-settings').click(); | ||
| await page.getByTestId('back-to-home').click(); | ||
| await page.getByTestId('open-settings').click(); | ||
|
|
||
| await expect(page.locator('text="Deutsch"')).toBeVisible(); | ||
| }); | ||
| }); | ||
|
|
||
| test.beforeEach(async () => { | ||
| await page.evaluate(async () => { | ||
| const dbNames = (await window.indexedDB.databases()).map( | ||
| (db) => db.name | ||
| ); | ||
| dbNames.forEach((name) => | ||
| name !== undefined ? window.indexedDB.deleteDatabase(name) : null | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| // eslint-disable-next-line no-empty-pattern | ||
| test.afterEach(async ({}, testInfo) => { | ||
| await page.screenshot({ | ||
| path: `./e2e/screenshots/home/${testInfo.title}.png`, | ||
| }); | ||
| }); | ||
|
|
||
| test.afterAll(async () => { | ||
| await page.close(); | ||
| await app.close(); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { devices, PlaywrightTestConfig } from '@playwright/test'; | ||
|
|
||
| const config: PlaywrightTestConfig = { | ||
| testDir: './e2e', | ||
| timeout: 45000, | ||
| maxFailures: 2, | ||
| testMatch: /.*\.e2e\.ts/, | ||
| projects: [ | ||
| { | ||
| name: 'chromium', | ||
| use: { | ||
| ...devices['Desktop Chrome'], | ||
| serviceWorkers: 'block', | ||
| }, | ||
| }, | ||
| /* { | ||
| name: 'firefox', | ||
| use: { | ||
| ...devices['Desktop Firefox'], | ||
| serviceWorkers: 'block', | ||
| }, | ||
| }, | ||
| { | ||
| name: 'webkit', | ||
| use: { | ||
| ...devices['Desktop Safari'], | ||
| serviceWorkers: 'block', | ||
| }, | ||
| }, */ | ||
| ], | ||
| use: { | ||
| headless: false, | ||
| screenshot: 'only-on-failure', | ||
| testIdAttribute: 'data-test-id', | ||
| }, | ||
| }; | ||
|
|
||
| export default config; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,31 +1,86 @@ | ||
| <ng-container *ngIf="allPlaylistsLoaded$ | async; else skeletonView"> | ||
| <mat-nav-list | ||
| cdkDropList | ||
| (cdkDropListDropped)="drop($event, playlists)" | ||
| *ngIf="playlists$ | async as playlists" | ||
| > | ||
| <mat-list-item *ngIf="playlists.length === 0"> | ||
| <mat-icon class="favorites-icon" mat-list-icon>search</mat-icon> | ||
| <div mat-line>{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}</div> | ||
| <div mat-line class="meta"> | ||
| {{ 'HOME.PLAYLISTS.ADD_FIRST' | translate }} | ||
| </div> | ||
| </mat-list-item> | ||
| <mat-list-item | ||
| *ngIf="playlists.length > 0" | ||
| (click)="getPlaylist('GLOBAL_FAVORITES')" | ||
| > | ||
| <mat-icon class="favorites-icon" mat-list-icon>star</mat-icon> | ||
| <div mat-line> | ||
| {{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate }} | ||
| </div> | ||
| <div mat-line class="meta"> | ||
| {{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES_DESCRIPTION' | translate }} | ||
| </div> | ||
| <mat-divider></mat-divider> | ||
| </mat-list-item> | ||
|
|
||
| <app-playlist-item | ||
| *ngFor="let item of playlists; last as last" | ||
| [item]="item" | ||
| (editPlaylistClicked)="openInfoDialog($event)" | ||
| (playlistClicked)="getPlaylist($event)" | ||
| (refreshClicked)="refreshPlaylist($event)" | ||
| (removeClicked)="removeClicked($event)" | ||
| ></app-playlist-item> | ||
|
|
||
| <div *ngIf="isMigrationPossible" id="migration-container"> | ||
| <p>{{ migrationMessage }}</p> | ||
| <button | ||
| mat-button | ||
| (click)="migratePlaylists()" | ||
| class="migration-btn" | ||
| color="accent" | ||
| > | ||
| <mat-icon>playlist_add</mat-icon> | ||
| Migrate playlists</button | ||
| > | ||
| <button | ||
| mat-button | ||
| (click)="deleteMigratedPlaylists()" | ||
| class="migration-btn" | ||
| color="accent" | ||
| > | ||
| <mat-icon>delete</mat-icon> | ||
| Delete all playlists | ||
| </button> | ||
| </div> | ||
| </mat-nav-list> | ||
| </ng-container> | ||
|
|
||
| <ng-template #skeletonView> | ||
| <div id="skeleton-container"> | ||
| <div class="skeleton-item" *ngFor="let a of [].constructor(10)"> | ||
| <ngx-skeleton-loader | ||
| count="1" | ||
| appearance="circle" | ||
| ></ngx-skeleton-loader> | ||
| <div class="text"> | ||
| <ngx-skeleton-loader | ||
| count="1" | ||
| [theme]="{ width: '30%', height: '20px' }" | ||
| ></ngx-skeleton-loader> | ||
| <ngx-skeleton-loader | ||
| count="1" | ||
| [theme]="{ width: '50%', height: '10px' }" | ||
| ></ngx-skeleton-loader> | ||
| </div> | ||
| <div class="actions"> | ||
| <ngx-skeleton-loader | ||
| count="2" | ||
| appearance="circle" | ||
| ></ngx-skeleton-loader> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </ng-template> |