Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
"allow": [
"Bash(tree:*)",
"WebSearch",
"WebFetch(domain:www.electronjs.org)"
"WebFetch(domain:www.electronjs.org)",
"Bash(npm run build:frontend:*)",
"Bash(cat:*)"
],
"deny": [],
"ask": []
Expand Down
66 changes: 5 additions & 61 deletions apps/web/src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,29 @@ import { provideMockStore } from '@ngrx/store/testing';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockComponent, MockModule, MockPipe, MockProviders } from 'ng-mocks';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { NgxWhatsNewComponent } from 'ngx-whats-new';
import { of } from 'rxjs';
import { DataService } from '../../../../libs/services/src/lib/data.service';
import { PlaylistsService } from '../../../../libs/services/src/lib/playlists.service';
import { Language } from '../../../../libs/shared/interfaces/src/lib/language.enum';
import { STORE_KEY } from '../../../../libs/shared/interfaces/src/lib/store-keys.enum';
import { Theme } from '../../../../libs/shared/interfaces/src/lib/theme.enum';
import { DataService, PlaylistsService } from 'services';
import { Language, STORE_KEY, Theme } from 'shared-interfaces';
import { AppComponent } from './app.component';
import { ElectronServiceStub } from './services/electron.service.stub';
import { SettingsService } from './services/settings.service';
import { WhatsNewService } from './services/whats-new.service';
import { WhatsNewServiceStub } from './services/whats-new.service.stub';

jest.spyOn(global.console, 'error').mockImplementation(() => {});
jest.spyOn(global.console, 'error').mockImplementation(() => {
// suppress console.error output during tests
});

describe('AppComponent', () => {
let component: AppComponent;
let electronService: DataService;
let fixture: ComponentFixture<AppComponent>;
let settingsService: SettingsService;
let translateService: TranslateService;
let whatsNewService: WhatsNewService;
const defaultLanguage = 'en';

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [AppComponent, MockPipe(TranslatePipe)],
providers: [
{ provide: WhatsNewService, useClass: WhatsNewServiceStub },
MockProviders(
TranslateService,
PlaylistsService,
Expand Down Expand Up @@ -68,7 +62,6 @@ describe('AppComponent', () => {
fixture = TestBed.createComponent(AppComponent);
settingsService = TestBed.inject(SettingsService);
translateService = TestBed.inject(TranslateService);
whatsNewService = TestBed.inject(WhatsNewService);
component = fixture.componentInstance;

// TODO: investigate in detail
Expand Down Expand Up @@ -128,14 +121,6 @@ describe('AppComponent', () => {
expect(router.navigateByUrl).toHaveBeenCalledWith(route);
}
));

it('show show whats new dialog', () => {
jest.spyOn(whatsNewService, 'getModalsByVersion');
jest.spyOn(component, 'setDialogVisibility');
component.showWhatsNewDialog();
expect(whatsNewService.getModalsByVersion).toHaveBeenCalledTimes(1);
expect(component.setDialogVisibility).toHaveBeenCalledWith(true);
});
});

describe('Test version handling', () => {
Expand All @@ -144,58 +129,17 @@ describe('AppComponent', () => {
const spyOnSettingsGet = jest
.spyOn(settingsService, 'getValueFromLocalStorage')
.mockReturnValue(of(currentAppVersion));
jest.spyOn(whatsNewService, 'getModalsByVersion').mockReturnValue([
{},
]);
jest.spyOn(whatsNewService, 'changeDialogVisibleState');

component.handleWhatsNewDialog();
expect(spyOnSettingsGet).toHaveBeenCalled();
expect(whatsNewService.getModalsByVersion).toHaveBeenCalled();
expect(
whatsNewService.changeDialogVisibleState
).toHaveBeenCalledWith(true);
});

it('should get actual app version which is not outdated and do not shop updates dialog', () => {
const currentAppVersion = '1.0.0';
const spyOnSettingsGet = jest
.spyOn(settingsService, 'getValueFromLocalStorage')
.mockReturnValue(of(currentAppVersion));
jest.spyOn(whatsNewService, 'getModalsByVersion').mockReturnValue([
{},
]);
jest.spyOn(whatsNewService, 'changeDialogVisibleState');

component.handleWhatsNewDialog();
expect(spyOnSettingsGet).toHaveBeenCalled();
expect(whatsNewService.getModalsByVersion).toHaveBeenCalledTimes(0);
expect(
whatsNewService.changeDialogVisibleState
).toHaveBeenCalledTimes(0);
});

it('should change the visibility of the whats new dialog', () => {
jest.spyOn(whatsNewService, 'changeDialogVisibleState');
component.modals = [{}, {}];
const visibilityFlag = true;
component.setDialogVisibility(true);

expect(
whatsNewService.changeDialogVisibleState
).toHaveBeenCalledWith(visibilityFlag);
expect(
whatsNewService.changeDialogVisibleState
).toHaveBeenCalledTimes(1);
});

it('should not change the visibility of the whats new dialog', () => {
jest.spyOn(whatsNewService, 'changeDialogVisibleState');
component.modals = [];
component.setDialogVisibility(true);
expect(
whatsNewService.changeDialogVisibleState
).toHaveBeenCalledTimes(0);
});
});

Expand Down
77 changes: 69 additions & 8 deletions apps/web/src/app/services/settings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { StorageMap } from '@ngx-pwa/local-storage';
import { catchError, map, Observable } from 'rxjs';
import * as semver from 'semver';
import { STORE_KEY, Theme } from 'shared-interfaces';

@Injectable({
Expand Down Expand Up @@ -52,26 +53,86 @@ export class SettingsService {

/**
* Returns the version of the released app
* Filters out pre-release versions (beta, alpha, rc)
*/
getAppVersion() {
return this.http
.get<{ created_at: string; name: string }[]>(
'https://api.github.com/repos/4gray/iptvnator/releases'
)
.pipe(
map(
(response) =>
response.sort(
(a, b) =>
new Date(b.created_at).getTime() -
new Date(a.created_at).getTime()
)[0]
),
map((response) => {
// Filter out pre-release versions (beta, alpha, rc, etc.)
const stableReleases = response.filter((release) => {
const releaseName = release.name.toLowerCase();

// Check for beta/alpha/rc keywords in the release name
const prereleaseKeywords = [
'beta',
'alpha',
'rc',
'preview',
'dev',
'canary',
'nightly',
];
const hasPrereleaseKeyword = prereleaseKeywords.some(
(keyword) => releaseName.includes(keyword)
);

if (hasPrereleaseKeyword) {
return false;
}

// Validate version format
const version = semver.valid(
semver.coerce(release.name)
);
if (!version) return false;

// Check if version has prerelease tags in semver format
return !semver.prerelease(release.name);
});

// Sort stable releases by creation date
const sortedReleases = stableReleases.sort(
(a, b) =>
new Date(b.created_at).getTime() -
new Date(a.created_at).getTime()
);

return sortedReleases[0];
}),
map((response) => response.name),
catchError((err) => {
console.error(err);
throw new Error(err);
})
);
}

/**
* Compares current version with latest version
* @param currentVersion current version of the app
* @param latestVersion latest stable version from GitHub
* @returns true if current version is outdated
*/
isVersionOutdated(
currentVersion: string,
latestVersion: string
): boolean {
// Clean and coerce versions to handle invalid formats
const cleanCurrent = semver.coerce(currentVersion);
const cleanLatest = semver.coerce(latestVersion);

if (!cleanCurrent || !cleanLatest) {
console.warn('Invalid version format:', {
currentVersion,
latestVersion,
});
return false;
}

return semver.lt(cleanCurrent, cleanLatest);
}
}
6 changes: 4 additions & 2 deletions apps/web/src/app/settings/settings.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import { DialogService } from 'components';
import * as PlaylistActions from 'm3u-state';
import { selectIsEpgAvailable } from 'm3u-state';
import { take } from 'rxjs';
import * as semver from 'semver';
import { DataService, EpgService, PlaylistsService } from 'services';
import {
Language,
Expand Down Expand Up @@ -242,7 +241,10 @@ export class SettingsComponent implements OnInit {
*/
isCurrentVersionOutdated(latestVersion: string): boolean {
this.version = this.dataService.getAppVersion();
return semver.lt(this.version, latestVersion);
return this.settingsService.isVersionOutdated(
this.version,
latestVersion
);
}

/**
Expand Down
114 changes: 114 additions & 0 deletions apps/web/src/app/xtream-tauri/account-info/account-info.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<h2 mat-dialog-title>{{ 'XTREAM.ACCOUNT_INFO.TITLE' | translate }}</h2>
<mat-dialog-content class="mat-typography">
@if (accountInfo?.user_info?.message) {
<div class="welcome-message">
{{ accountInfo?.user_info?.message }}
</div>
}
<div class="info-grid">
<div class="info-section">
<h3>{{ 'XTREAM.ACCOUNT_INFO.USER_INFO' | translate }}</h3>
<mat-list>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.STATUS' | translate }}:
<span [class.active]="isActive">{{
accountInfo?.user_info?.status
}}</span>
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.USERNAME' | translate }}:
{{ accountInfo?.user_info?.username }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.ACTIVE_CONNECTIONS' | translate }}:
{{ accountInfo?.user_info?.active_cons }}/{{
accountInfo?.user_info?.max_connections
}}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.CREATED' | translate }}:
{{ formattedCreatedDate }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.EXPIRES' | translate }}:
{{ formattedExpDate }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.TRIAL_ACCOUNT' | translate }}:
<span [class.trial]="isTrial">{{
(isTrial
? 'XTREAM.ACCOUNT_INFO.YES'
: 'XTREAM.ACCOUNT_INFO.NO'
) | translate
}}</span>
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.ALLOWED_FORMATS' | translate }}:
<span class="formats">
{{
accountInfo?.user_info?.allowed_output_formats?.join(
', '
)
}}
</span>
</mat-list-item>
</mat-list>
</div>

<div class="info-section">
<h3>{{ 'XTREAM.ACCOUNT_INFO.SERVER_INFO' | translate }}</h3>
<mat-list>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.LIVE_TV' | translate }}:
{{ liveStreamsCount }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.MOVIES' | translate }}:
{{ vodStreamsCount }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.TV_SERIES' | translate }}:
{{ seriesCount }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.URL' | translate }}:
{{ accountInfo?.server_info?.url }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.PROTOCOL' | translate }}:
{{ accountInfo?.server_info?.server_protocol }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.TIMEZONE' | translate }}:
{{ accountInfo?.server_info?.timezone }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.SERVER_TIME' | translate }}:
{{ accountInfo?.server_info?.time_now }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.PORTS' | translate }}:
<div class="ports">
<span
>{{ 'XTREAM.ACCOUNT_INFO.HTTP_PORT' | translate }}:
{{ accountInfo?.server_info?.port }}</span
>
<span
>{{ 'XTREAM.ACCOUNT_INFO.HTTPS_PORT' | translate }}:
{{ accountInfo?.server_info?.https_port }}</span
>
<span
>{{ 'XTREAM.ACCOUNT_INFO.RTMP_PORT' | translate }}:
{{ accountInfo?.server_info?.rtmp_port }}</span
>
</div>
</mat-list-item>
</mat-list>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close color="accent">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
Loading