diff --git a/CHANGELOG.md b/CHANGELOG.md index 97a8ab5d4..1e9fe897e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ - Added `user_config_dir` to `config.yml`. It is the storage path for user configurations (f.e. bouquets). - New Filter field `input` can be used along `name`, `group`, `title`, `url` and `type`. Input is a `regexp` filter. `input ~ "provider\-\d+"` - New option `use_user_db` in `api-proxy.yml`. The Playlist Users are stored inside the config file `api-proxy.yml`. When you set this option to `true` -the user are stored in a db file. This is a better choice if you have a lot of users. If you have only a few let it defaultto `false` +the user are stored in a db file. This is a better choice if you have a lot of users. If you have only a few let it default to `false` # 2.2.1 (2025-02-14) - Added more info to `/status`. diff --git a/README.md b/README.md index 3a4a8f5a1..fb1b524ab 100644 --- a/README.md +++ b/README.md @@ -501,6 +501,11 @@ If `kodi_style` set to `true` the property `#KODIPROP:seekable=true|false` is ad - `xtream_skip_video_direct_source` if true the direct_source property from provider for movies is ignored - `xtream_skip_series_direct_source` if true the direct_source property from provider for series is ignored +Iptv player can act differently and use the direct-source attribute or can compose the url based on the server info. +The options `xtream_skip_live_direct_source`, `xtream_skip_video_direct_source` and`xtream_skip_series_direct_source` +are default `true` to avoid this problem. +You can set them fo `false`to keep the direct-source attribute. + Because xtream api delivers only the metadata to series, we need to fetch the series and resolve them. But be aware, each series info entry needs to be fetched one by one and the provider can ban you if you are doing request too frequently. - `xtream_resolve_series` if is set to `true` and you have xtream input and m3u output, the series are fetched and resolved. @@ -522,6 +527,7 @@ There is a difference for `xtream_resolve_vod` and `xtream_resolve_series`. `xtream_resolve_series` works only when input: `xtream` and output: `m3u`. `xtream_resolve_vod` works only when input: `xtream`. + ### 2.2.2.5 `filter` The filter is a string with a filter statement. The filter can have UnaryExpression `NOT`, BinaryExpression `AND OR`, Regexp Comparison `(Group|Title|Name|Url) ~ "regexp"` @@ -920,17 +926,48 @@ mappings: ``` ## 3. Api-Proxy Config -If you use the proxy functionality, -you need to create a `api-proxy.yml` configuration. -You can specify the path for the file with the `-a` cli argument. +If you use m3u-filter to deliver playlists, we require a configuration to provide the necessary server information, rewrite URLs in reverse proxy mode, and define users who can access the API. -The configuration contains the server info for xtream accounts and user definitions. -You can define multiple server with unique names, one should be named `default`. +For this purpose, we use the `api-proxy.yml` configuration. -Iptv player can act differently and use the direct-source attribute or can compose the url based on the server info. -The options `xtream_skip_live_direct_source`, `xtream_skip_video_direct_source` and `xtream_skip_series_direct_source` are default `true` to avoid this problem. -You can set them fo `false`to keep the direct-source attribute. +You can specify the path to the file using the `-a` CLI argument. + +You can define multiple servers with unique names; typically, two are defined—one for the local network and one for external access. +One server should be named `default`. + +```yaml +server: + - name: default + protocol: http + host: 192.169.1.9 + port: '8901' + timezone: Europe/Paris + message: Welcome to m3u-filter + - name: external + protocol: https + host: m3ufilter.mydomain.tv + port: '443' + timezone: Europe/Paris + message: Welcome to m3u-filter + path: m3uflt +``` + +User definitions are made for the targets. Each target can have multiple users. Usernames and tokens must be unique. + +```yaml +user: +- target: xc_m3u + credentials: + - username: test1 + password: secret1 + token: 'token1' + proxy: reverse + server: default + exp_date: 1672705545 + max_connections: 1 + status: Active +``` `username` and `password`are mandatory for credentials. `username` is unique. The `token` is _optional_. If defined it should be unique. The `token`can be used @@ -939,6 +976,20 @@ instead of username+password `server` is _optional_. It should match one server definition, if not given the server with the name `default` is used or the first one. `epg_timeshift` is _optional_. It is only applied when source has `epg_url` configured. `epg_timeshift: [-+]hh:mm`, example `-2:30`(-2h30m), `1:45` (1h45m), `+0:15` (15m), `2` (2h), `:30` (30m), `:3` (3m), `2:` (3h) +- `max_connections` is _optional_ +- `status` is _optional_ +- `exp_date` is _optional_ + +`max_connections`, `status` and `exp_date` are only used when `user_access_control` ist ste to true. + + +If you have a lot of users and dont want to keep them in `api-proxy.yml`, you can set the option +- `use_user_db` to true to store the user information inside a db-file. + +If the `use_user_db` option is switched to `false` or `true`, the users will automatically +be migrated to the corresponding file (`false` → `api_proxy.yml`, `true` → `api_user.db`). + +If you set `use_user_db` to `true` you need to use the `Web-UI` to `edit`/`add`/`remove` users. To access the api for: - `xtream` use url like `http://192.169.1.2/player_api.php?username={}&password={}` @@ -966,6 +1017,7 @@ If you use `https` you need a ssl terminator. `m3u-filter` does not support http If you use a ssl-terminator or proxy in front of m3u-filter you can set a `path` to make the configuration of your proxy simpler. For example you use `nginx` as your reverse proxy. + `api-proxy.yml` ```yaml server: @@ -982,6 +1034,17 @@ server: timezone: Europe/Paris message: Welcome to m3u-filter path: m3uflt +user: + - target: xc_m3u + credentials: + - username: test1 + password: secret1 + token: 'token1' + proxy: reverse + server: default + exp_date: 1672705545 + max_connections: 1 + status: Active ``` Now you can do `nginx` configuration like diff --git a/frontend/public/i18n/en_common.json b/frontend/public/i18n/en_common.json index af9dfdc66..646feac3b 100644 --- a/frontend/public/i18n/en_common.json +++ b/frontend/public/i18n/en_common.json @@ -73,7 +73,9 @@ "MESSAGE": "Message", "PROTOCOL": "Protocol", "NAME": "Name", - "API_PROXY": "Api-Proxy" + "API_PROXY": "Api-Proxy", + "INFINITE": "Infinite", + "ONE_YEAR": "One year" }, "TITLE": { "USER_BOUQUET_EDITOR": "User group editor" @@ -106,6 +108,7 @@ "FAIL": "Failed to save user!", "USERNAME_REQUIRED": "Username required", "DUPLICATE_USERNAME": "Duplicate username!", + "DUPLICATE_TOKEN": "Duplicate token!", "TARGET_NOT_FOUND": "Target not found!", "MAX_CONNECTIONS_INVALID": "MaxConnections invalid" }, diff --git a/frontend/src/component/date-picker/date-picker.scss b/frontend/src/component/date-picker/date-picker.scss index da7804706..14f21c40c 100644 --- a/frontend/src/component/date-picker/date-picker.scss +++ b/frontend/src/component/date-picker/date-picker.scss @@ -1,11 +1,54 @@ +@use '../../scss/common'; @import "react-datepicker/dist/react-datepicker.css"; +.date-picker-container { + display: flex; + flex-flow: row nowrap; + align-items: center; + gap: 4px; + + &__toolbar { + display: flex; + flex-flow: row nowrap; + align-items: center; + gap: 4px; + button { + @include common.iconButton(); + } + svg { + fill: var(--icon-button-color); + } + } +} + +.react-datepicker-wrapper { + box-sizing: border-box; + overflow: hidden; +} + .react-datepicker__header { h2 { display: none; } } +.react-datepicker__navigation--years-upcoming { + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 12px solid var(--border-color); + transform: translateY(4px); +} +.react-datepicker__navigation--years-previous { + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 12px solid var(--border-color); + transform: translateY(-4px); +} + .react-datepicker__input-container { display: flex; flex-flow: row-reverse nowrap; @@ -15,6 +58,7 @@ border-radius: var(--border-radius); user-select: none; cursor: pointer; + box-sizing: border-box; input { user-select: none; diff --git a/frontend/src/component/date-picker/date-picker.tsx b/frontend/src/component/date-picker/date-picker.tsx index 3191ede8a..1833c5397 100644 --- a/frontend/src/component/date-picker/date-picker.tsx +++ b/frontend/src/component/date-picker/date-picker.tsx @@ -2,6 +2,7 @@ import React, {useCallback, useEffect} from "react"; import "./date-picker.scss"; import ReactDatePicker from "react-datepicker"; import {getIconByName} from "../../icons/icons"; +import useTranslator from "../../hook/use-translator"; interface DatePickerProps { @@ -12,6 +13,7 @@ interface DatePickerProps { export default function DatePicker(props: DatePickerProps) { const {name, value, onChange} = props; + const translate= useTranslator(); const [selected, setSelected] = React.useState(value); const datePickerRef = React.useRef(null); @@ -31,19 +33,36 @@ export default function DatePicker(props: DatePickerProps) { } }, []); + const handleInfiniteDate = useCallback(() => { + handleDateChange(new Date(0)); + }, [handleDateChange]); + + const handleNowDate = useCallback(() => { + const date = new Date(); + date.setFullYear(date.getFullYear() + 1); + handleDateChange(date); + }, [handleDateChange]); + return ( -
- - +
+
+ + +
+
+ + +
); } diff --git a/frontend/src/component/user-view/user-view.scss b/frontend/src/component/user-view/user-view.scss index e031cd63a..95ebc495b 100644 --- a/frontend/src/component/user-view/user-view.scss +++ b/frontend/src/component/user-view/user-view.scss @@ -1,6 +1,26 @@ @use '../../scss/common'; @use '../../scss/preferences'; +.proxy-type { + &-reverse { + } + &-redirect { + color: #fffa00; + } +} + +.status { + &-active, .status-trial { + color: var(--ok-color); + } + &-banned { + color: var(--attention-color); + } + &-expired, &-disabled, &-pending { + color: var(--warn-color); + } +} + .tag-select { min-width: 160px; } diff --git a/frontend/src/component/user-view/user-view.tsx b/frontend/src/component/user-view/user-view.tsx index 5c71a3021..77e4a31a5 100644 --- a/frontend/src/component/user-view/user-view.tsx +++ b/frontend/src/component/user-view/user-view.tsx @@ -12,15 +12,43 @@ import PlaylistFilter from "../playlist-filter/playlist-filter"; import UserEditor from "../user-editor/user-editor"; import TextGenerator from "../../utils/text-generator"; +const renderExpDate = (value: any, hidden?: boolean) => { + if (!value) { + return getIconByName('Unlimited'); + } + return DateUtils.formatDate(value) +}; + +const renderMaxCon = (value: any, hidden?: boolean) => { + if (!value) { + return getIconByName('Unlimited'); + } + return value; +}; + +const renderStatus = (value: any, hidden?: boolean) => { + if (value) { + return {value} + } + return value; +}; + +const renderProxyType = (value: any, hidden?: boolean) => { + if (value) { + return {value} + } + return value; +}; + const COLUMNS = [ {field: 'username', label: 'LABEL.USERNAME'}, {field: 'password', label: 'LABEL.PASSWORD', hidden: true}, {field: 'token', label: 'LABEL.TOKEN', hidden: true}, {field: 'server', label: 'LABEL.SERVER'}, - {field: 'proxy', label: 'LABEL.PROXY'}, - {field: 'max_connections', label: 'LABEL.MAX_CON'}, - {field: 'status', label: 'LABEL.STATUS'}, - {field: 'exp_date', label: 'LABEL.EXP_DATE', render: (value: any, hidden?: boolean) => DateUtils.formatDate(value)}, + {field: 'proxy', label: 'LABEL.PROXY', render: renderProxyType}, + {field: 'max_connections', label: 'LABEL.MAX_CON', render: renderMaxCon}, + {field: 'status', label: 'LABEL.STATUS', render: renderStatus}, + {field: 'exp_date', label: 'LABEL.EXP_DATE', render: renderExpDate}, ] COLUMNS.forEach(col => { @@ -33,7 +61,10 @@ const prepareCredentials = (targetUser: TargetUser[]) => { targetUser.forEach((user) => { user.credentials.forEach((credential) => { if (credential.exp_date) { - credential.exp_date = new Date(credential.exp_date * 1000) as any; + credential.exp_date = DateUtils.unixSecondsToDate(credential.exp_date); + } + if (credential.created_at) { + credential.created_at = DateUtils.unixSecondsToDate(credential.created_at); } }) }); @@ -41,13 +72,12 @@ const prepareCredentials = (targetUser: TargetUser[]) => { const prepareTargetUserForSave = (targetUser: TargetUser[]): TargetUser[] => { return targetUser.map((user) => { - let newUser = {...user, credentials: user.credentials.map(c => ({...c}))}; - newUser.credentials.forEach((credential) => { - if (credential.exp_date) { - credential.exp_date = Math.floor((credential.exp_date as any).getTime() / 1000); - } + let storeUser = {...user, credentials: user.credentials.map(c => ({...c}))}; + storeUser.credentials.forEach((credential) => { + credential.exp_date = DateUtils.toUnixSeconds(credential.exp_date); + credential.created_at = DateUtils.toUnixSeconds(credential.created_at); }); - return newUser; + return storeUser; }); } @@ -93,15 +123,17 @@ const createNewUser = (targets: TargetUser[]): Credentials => { break; } } - const created_at = Math.floor(Date.now() / 1000); + const created_at = Date.now(); + const exp_date = new Date(); + exp_date.setFullYear(exp_date.getFullYear()+1); return { username, password: TextGenerator.generatePassword(), token: TextGenerator.generatePassword(), proxy: 'reverse', created_at, - exp_date: undefined, - max_connections: undefined, + exp_date: exp_date.getTime(), + max_connections: 1, status: "Active", // @ts-ignore _ref: undefined, // an indicator for new user @@ -203,6 +235,7 @@ export default function UserView(props: UserViewProps) { const handleSave = useCallback(() => { const usernames: any = {}; + const tokens: any = {}; for (const target of targets) { for (const user of target.credentials) { const err = checkuser(user); @@ -210,7 +243,18 @@ export default function UserView(props: UserViewProps) { enqueueSnackbar(translate(err), {variant: 'error'}); return; } + if (usernames[user.username]) { + enqueueSnackbar(translate("MESSAGES.USER.DUPLICATE_USERNAME") + user.username, {variant: 'error'}); + return; + } usernames[user.username] = true; + if (user.token) { + if (tokens[user.token]) { + enqueueSnackbar(translate("MESSAGES.USER.DUPLICATE_TOKEN") + user.token, {variant: 'error'}); + return; + } + tokens[user.token] = true; + } } } const targetUser = targets.map(t => { diff --git a/frontend/src/icons/icons.tsx b/frontend/src/icons/icons.tsx index 6a6861238..4f63ccb06 100644 --- a/frontend/src/icons/icons.tsx +++ b/frontend/src/icons/icons.tsx @@ -51,7 +51,9 @@ const DEFAULT_ICONS = { DeselectAll: 'M 2,5 V 7.1538458 H 13.612371 V 5 Z M 2,9.3076922 V 11.461539 H 13.612371 V 9.3076922 Z m 14.082473,1.7899618 -1.265978,1.392431 2.327832,2.559791 -2.327832,2.557693 L 16.082473,19 18.408247,16.442307 20.734022,19 22,17.607569 19.674225,15.049876 22,12.490085 20.734022,11.097654 18.408247,13.655347 Z M 2,13.615385 v 2.153847 h 7.3896896 v -2.153847 z', Visibility: 'M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5M12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5m0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3', Edit: 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.996.996 0 0 0-1.41 0l-1.83 1.83 3.75 3.75z', - Calendar: 'M19 4h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2m0 16H5V10h14zM9 14H7v-2h2zm4 0h-2v-2h2zm4 0h-2v-2h2zm-8 4H7v-2h2zm4 0h-2v-2h2zm4 0h-2v-2h2z' + Calendar: 'M19 4h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2m0 16H5V10h14zM9 14H7v-2h2zm4 0h-2v-2h2zm4 0h-2v-2h2zm-8 4H7v-2h2zm4 0h-2v-2h2zm4 0h-2v-2h2z', + Unlimited: 'M18.6 6.62c-1.44 0-2.8.56-3.77 1.53L12 10.66 10.48 12h.01L7.8 14.39c-.64.64-1.49.99-2.4.99-1.87 0-3.39-1.51-3.39-3.38S3.53 8.62 5.4 8.62c.91 0 1.76.35 2.44 1.03l1.13 1 1.51-1.34L9.22 8.2C8.2 7.18 6.84 6.62 5.4 6.62 2.42 6.62 0 9.04 0 12s2.42 5.38 5.4 5.38c1.44 0 2.8-.56 3.77-1.53l2.83-2.5.01.01L13.52 12h-.01l2.69-2.39c.64-.64 1.49-.99 2.4-.99 1.87 0 3.39 1.51 3.39 3.38s-1.52 3.38-3.39 3.38c-.9 0-1.76-.35-2.44-1.03l-1.14-1.01-1.51 1.34 1.27 1.12c1.02 1.01 2.37 1.57 3.82 1.57 2.98 0 5.4-2.41 5.4-5.38s-2.42-5.37-5.4-5.37', + Today: 'M16.53 11.06 15.47 10l-4.88 4.88-2.12-2.12-1.06 1.06L10.59 17zM19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2m0 16H5V8h14z' } diff --git a/frontend/src/scss/_theme.scss b/frontend/src/scss/_theme.scss index 4f97795e0..ca47348f2 100644 --- a/frontend/src/scss/_theme.scss +++ b/frontend/src/scss/_theme.scss @@ -34,6 +34,7 @@ --attention-color: red; --warn-color: orange; + --ok-color: #21e821; --card-background-color: #202733; //#282828; --card-color: #ffffff; diff --git a/frontend/src/utils/date-utils.ts b/frontend/src/utils/date-utils.ts index b63f994d4..26ca53d72 100644 --- a/frontend/src/utils/date-utils.ts +++ b/frontend/src/utils/date-utils.ts @@ -2,11 +2,18 @@ function formatDate(date: any): string { if (typeof date === 'string') { return date; } - const day = String(date.getDate()).padStart(2, '0'); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const year = date.getFullYear(); + if (typeof date === 'number') { + date = new Date(date); - return `${year}-${month}-${day}`; + } + if (typeof date?.getTime === 'function') { + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + + return `${year}-${month}-${day}`; + } + return ''; } const DateUtils = { @@ -15,6 +22,30 @@ const DateUtils = { return formatDate(date); } return ''; + }, + toUnixSeconds: (date: any) => { + if (date && typeof date.getTime == 'function') { + if (date.getTime() > 0) { + return Math.floor(date.getTime() / 1000); + } + return 0; + } + if (date && typeof date == 'number') { + if ( date > 0) { + return Math.floor(date / 1000); + } + return 0; + } + return date; + }, + unixSecondsToDate: (date: any) => { + if (date && typeof date == 'number') { + return new Date(date * 1000); + } + if (date && typeof date.getTime == 'function') { + return date; + } + return date; } } diff --git a/src/api/endpoints/v1_api.rs b/src/api/endpoints/v1_api.rs index 53cc4e23e..b1689f1c7 100644 --- a/src/api/endpoints/v1_api.rs +++ b/src/api/endpoints/v1_api.rs @@ -50,6 +50,7 @@ async fn save_config_api_proxy_user( ) -> HttpResponse { let mut users = req.0; let mut usernames = HashSet::new(); + let mut tokens = HashSet::new(); for target_user in &mut users { for credential in &mut target_user.credentials { credential.trim(); @@ -60,6 +61,12 @@ async fn save_config_api_proxy_user( return HttpResponse::BadRequest().json(json!({"error": format!("Duplicate username {}", &credential.username)})); } usernames.insert(&credential.username); + if let Some(token) = &credential.token { + if tokens.contains(token) { + return HttpResponse::BadRequest().json(json!({"error": format!("Duplicate token {token}")})); + } + tokens.insert(token); + } } } diff --git a/src/api/model/streams/provider_stream_factory.rs b/src/api/model/streams/provider_stream_factory.rs index ac4c54ba2..d731aa4d5 100644 --- a/src/api/model/streams/provider_stream_factory.rs +++ b/src/api/model/streams/provider_stream_factory.rs @@ -387,56 +387,56 @@ pub async fn create_provider_stream(cfg: &Config, } } -#[cfg(test)] -mod tests { - use crate::api::model::streams::provider_stream_factory::PlaylistItemType; - use crate::api::model::streams::provider_stream_factory::{create_provider_stream, BufferStreamOptions}; - use actix_web::test; - use actix_web::test::TestRequest; - use actix_web::web; - use actix_web::App; - use actix_web::{HttpRequest, HttpResponse}; - use futures::StreamExt; - use std::sync::Arc; - use crate::model::config::Config; - - #[actix_rt::test] - async fn test_stream() { - let app = App::new().route("/test", web::get().to(test_stream_handler)); - let server = test::init_service(app).await; - let req = TestRequest::get().uri("/test").to_request(); - let _response = test::call_service(&server, req).await; - } - async fn test_stream_handler(req: HttpRequest) -> HttpResponse { - let cfg = Config::default(); - let mut counter = 5; - let client = Arc::new(reqwest::Client::new()); - let url = url::Url::parse("https://info.cern.ch/hypertext/WWW/TheProject.html").unwrap(); - let input = None; - - let options = BufferStreamOptions::new(PlaylistItemType::Live, true, true, 0, false); - let value = create_provider_stream(&cfg, Arc::clone(&client), &url, &req, input, options); - let mut values = value.await; - 'outer: while let Some((ref mut stream, info)) = values.as_mut() { - if info.is_some() { - println!("{:?}", info.as_ref().unwrap()); - } - while let Some(result) = stream.next().await { - match result { - Ok(bytes) => { - println!("Received {} bytes {bytes:?}", bytes.len()); - counter -= 1; - if counter < 0 { - break 'outer; - } - } - Err(err) => { - eprintln!("Error occurred: {}", err); - break 'outer; - } - } - } - } - HttpResponse::Ok().finish() - } -} +// #[cfg(test)] +// mod tests { +// use crate::api::model::streams::provider_stream_factory::PlaylistItemType; +// use crate::api::model::streams::provider_stream_factory::{create_provider_stream, BufferStreamOptions}; +// use actix_web::test; +// use actix_web::test::TestRequest; +// use actix_web::web; +// use actix_web::App; +// use actix_web::{HttpRequest, HttpResponse}; +// use futures::StreamExt; +// use std::sync::Arc; +// use crate::model::config::Config; +// +// #[actix_rt::test] +// async fn test_stream() { +// let app = App::new().route("/test", web::get().to(test_stream_handler)); +// let server = test::init_service(app).await; +// let req = TestRequest::get().uri("/test").to_request(); +// let _response = test::call_service(&server, req).await; +// } +// async fn test_stream_handler(req: HttpRequest) -> HttpResponse { +// let cfg = Config::default(); +// let mut counter = 5; +// let client = Arc::new(reqwest::Client::new()); +// let url = url::Url::parse("https://info.cern.ch/hypertext/WWW/TheProject.html").unwrap(); +// let input = None; +// +// let options = BufferStreamOptions::new(PlaylistItemType::Live, true, true, 0, false); +// let value = create_provider_stream(&cfg, Arc::clone(&client), &url, &req, input, options); +// let mut values = value.await; +// 'outer: while let Some((ref mut stream, info)) = values.as_mut() { +// if info.is_some() { +// println!("{:?}", info.as_ref().unwrap()); +// } +// while let Some(result) = stream.next().await { +// match result { +// Ok(bytes) => { +// println!("Received {} bytes {bytes:?}", bytes.len()); +// counter -= 1; +// if counter < 0 { +// break 'outer; +// } +// } +// Err(err) => { +// eprintln!("Error occurred: {}", err); +// break 'outer; +// } +// } +// } +// } +// HttpResponse::Ok().finish() +// } +// } diff --git a/src/main.rs b/src/main.rs index eb5b10c47..b076339ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -#![warn(clippy::pedantic)] +#![warn(clippy::all,clippy::pedantic)] #![allow(clippy::module_name_repetitions)] #![allow(clippy::must_use_candidate)] #![allow(clippy::return_self_not_must_use)] diff --git a/src/model/api_proxy.rs b/src/model/api_proxy.rs index 1554c1b42..eb7a44cde 100644 --- a/src/model/api_proxy.rs +++ b/src/model/api_proxy.rs @@ -1,7 +1,7 @@ use crate::api::model::app_state::AppState; use crate::m3u_filter_error::{create_m3u_filter_error_result, info_err, M3uFilterError, M3uFilterErrorKind}; use crate::model::config::Config; -use crate::repository::user_repository::{get_api_user_db_path, load_api_user, merge_api_user}; +use crate::repository::user_repository::{backup_api_user_db_file, get_api_user_db_path, load_api_user, merge_api_user}; use crate::utils::file::config_reader; use chrono::Local; use enum_iterator::Sequence; @@ -304,7 +304,7 @@ impl ApiProxyConfig { let api_proxy_file = cfg.t_api_proxy_file_path.as_str(); let backup_dir = cfg.backup_dir.as_ref().unwrap().as_str(); self.user = vec![]; - if let Err(err) = config_reader::save_api_proxy(api_proxy_file, backup_dir, &self) { + if let Err(err) = config_reader::save_api_proxy(api_proxy_file, backup_dir, self) { errors.push(format!("Error saving api proxy file: {err}")); } } @@ -315,7 +315,7 @@ impl ApiProxyConfig { } Err(err) => { println!("{err}"); - errors.push(err.to_string()) + errors.push(err.to_string()); }, }; } else { @@ -326,8 +326,8 @@ impl ApiProxyConfig { if let Ok(stored_users) = load_api_user(cfg) { for stored_user in stored_users { if let Some(target_user) = self.user.iter_mut().find(|t| t.target == stored_user.target) { - for stored_credential in stored_user.credentials.iter() { - if target_user.credentials.iter().find(|&c| c.username == stored_credential.username).is_none() { + for stored_credential in &stored_user.credentials { + if !target_user.credentials.iter().any(|c| c.username == stored_credential.username) { target_user.credentials.push(stored_credential.clone()); }; } @@ -338,9 +338,10 @@ impl ApiProxyConfig { } let api_proxy_file = cfg.t_api_proxy_file_path.as_str(); let backup_dir = cfg.backup_dir.as_ref().unwrap().as_str(); - if let Err(err) = config_reader::save_api_proxy(api_proxy_file, backup_dir, &self) { + if let Err(err) = config_reader::save_api_proxy(api_proxy_file, backup_dir, self) { errors.push(format!("Error saving api proxy file: {err}")); } else { + backup_api_user_db_file(cfg, &user_db_path); let _ = fs::remove_file(&user_db_path); } } diff --git a/src/repository/user_repository.rs b/src/repository/user_repository.rs index c36defac8..ee15ac213 100644 --- a/src/repository/user_repository.rs +++ b/src/repository/user_repository.rs @@ -1,79 +1,137 @@ -use crate::model::api_proxy::{ProxyUserCredentials, TargetUser}; +use crate::model::api_proxy::{ProxyType, ProxyUserCredentials, ProxyUserStatus, TargetUser}; use crate::model::config::Config; use crate::model::playlist::XtreamCluster; use crate::model::playlist_categories::{PlaylistCategoriesDto, PlaylistCategoryDto}; use crate::repository::bplustree::BPlusTree; use crate::utils::file::file_utils; use crate::utils::json_utils::json_write_documents_to_file; -use log::error; +use log::{error}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use chrono::Local; const USER_LIVE_BOUQUET: &str = "live_bouquet.json"; const USER_VOD_BOUQUET: &str = "vod_bouquet.json"; const USER_SERIES_BOUQUET: &str = "series_bouquet.json"; - +const API_USER_DB_FILE: &str = "api_user.db"; // This is a Helper class to store all user into one Database file. // For the Config files we keep the old structure where a user is assigned to a target. // But for storing inside one db file it is easier to store the target next to the user. +// due to known issue with bincode and skip_serialization_if we have to list all fields and cant use ProxyUserCredentials #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] struct StoredProxyUserCredentials { pub target: String, - pub user: ProxyUserCredentials, + pub username: String, + pub password: String, + pub token: Option, + pub proxy: ProxyType, + pub server: Option, + pub epg_timeshift: Option, + pub created_at: Option, + pub exp_date: Option, + pub max_connections: Option, + pub status: Option, } +impl StoredProxyUserCredentials { + fn from(proxy: &ProxyUserCredentials, target_name: &str) -> Self { + Self { + target: String::from(target_name), + username: proxy.username.clone(), + password: proxy.password.clone(), + token: proxy.token.clone(), + proxy: proxy.proxy.clone(), + server: proxy.server.clone(), + epg_timeshift: proxy.epg_timeshift.clone(), + created_at: proxy.created_at, + exp_date: proxy.exp_date, + max_connections: proxy.max_connections, + status: proxy.status.clone(), + } + } + + fn to(stored: &StoredProxyUserCredentials) -> ProxyUserCredentials { + ProxyUserCredentials { + username: stored.username.clone(), + password: stored.password.clone(), + token: stored.token.clone(), + proxy: stored.proxy.clone(), + server: stored.server.clone(), + epg_timeshift: stored.epg_timeshift.clone(), + created_at: stored.created_at, + exp_date: stored.exp_date, + max_connections: stored.max_connections, + status: stored.status.clone(), + } + } +} + + pub fn get_api_user_db_path(cfg: &Config) -> PathBuf { - PathBuf::from(&cfg.t_config_path).join("api_user.db") + PathBuf::from(&cfg.t_config_path).join(API_USER_DB_FILE) } fn add_target_user_to_user_tree(target_users: &[TargetUser], user_tree: &mut BPlusTree) { for target_user in target_users { for user in &target_user.credentials { - let store_user = StoredProxyUserCredentials { - target: target_user.target.clone(), - user: user.clone(), - }; + let store_user: StoredProxyUserCredentials = StoredProxyUserCredentials::from(user, &target_user.target); user_tree.insert(user.username.clone(), store_user); } } } pub fn merge_api_user(cfg: &Config, target_users: &[TargetUser]) -> Result { - let path = get_api_user_db_path(&cfg); - let _lock = cfg.file_locks.read_lock(&path); + let path = get_api_user_db_path(cfg); + let lock = cfg.file_locks.read_lock(&path); let mut user_tree: BPlusTree = BPlusTree::load(&path).unwrap_or_else(|_| BPlusTree::new()); - drop(_lock); + drop(lock); add_target_user_to_user_tree(target_users, &mut user_tree); let _lock = cfg.file_locks.write_lock(&path); user_tree.store(&path) } +/// # Panics +/// +/// Will panic if `backup_dir` is not given +pub fn backup_api_user_db_file(cfg: &Config, path: &Path) { + let backup_dir = cfg.backup_dir.as_ref().unwrap().as_str(); + let backup_path = PathBuf::from(backup_dir).join(format!("{API_USER_DB_FILE}_{}", Local::now().format("%Y%m%d_%H%M%S"))); + let _lock = cfg.file_locks.read_lock(path); + match std::fs::copy(path, &backup_path) { + Ok(_) => {} + Err(err) => { error!("Could not backup file {}:{}", &backup_path.to_str().unwrap_or("?"), err) } + } +} + pub fn store_api_user(cfg: &Config, target_users: &[TargetUser]) -> Result { let mut user_tree = BPlusTree::::new(); add_target_user_to_user_tree(target_users, &mut user_tree); - let path = get_api_user_db_path(&cfg); + let path = get_api_user_db_path(cfg); + backup_api_user_db_file(cfg, &path); let _lock = cfg.file_locks.write_lock(&path); user_tree.store(&path) } pub fn load_api_user(cfg: &Config) -> Result, std::io::Error> { - let path = get_api_user_db_path(&cfg); - let _lock = cfg.file_locks.read_lock(&path); + let path = get_api_user_db_path(cfg); + let lock = cfg.file_locks.read_lock(&path); let user_tree = BPlusTree::::load(&path)?; - drop(_lock); + drop(lock); let mut target_users: HashMap = HashMap::new(); - for (_uname, user_wrapper) in user_tree.iter() { - match target_users.entry(user_wrapper.target.clone()) { + for (_uname, stored_user) in &user_tree { + let proxy_user: ProxyUserCredentials = StoredProxyUserCredentials::to(stored_user); + let target_name = stored_user.target.clone(); + match target_users.entry(target_name) { std::collections::hash_map::Entry::Occupied(mut entry) => { let target = entry.get_mut(); - target.credentials.push(user_wrapper.user.clone()); + target.credentials.push(proxy_user); } std::collections::hash_map::Entry::Vacant(entry) => { entry.insert(TargetUser { - target: user_wrapper.target.clone(), - credentials: vec![user_wrapper.user.clone()], + target: stored_user.target.clone(), + credentials: vec![proxy_user], }); } } @@ -189,4 +247,81 @@ pub async fn user_get_bouquet_filter(config: &Config, username: &str, category_i } else { Some(filter) } +} + + +#[cfg(test)] +mod tests { + use std::env::temp_dir; + use crate::model::api_proxy::{ProxyType, ProxyUserStatus}; + use super::*; + + + #[test] + pub fn save_target_user() { + let user = + TargetUser { + target: "test".to_string(), + credentials: vec![ + ProxyUserCredentials { + username: "Test".to_string(), + password: "Test".to_string(), + token: Some("Test".to_string()), + proxy: ProxyType::Reverse, + server: Some("default".to_string()), + epg_timeshift: None, + created_at: None, + exp_date: Some(1672705545), + max_connections: Some(1), + status: Some(ProxyUserStatus::Active), + }, + ProxyUserCredentials { + username: "Test2".to_string(), + password: "Test".to_string(), + token: Some("Test".to_string()), + proxy: ProxyType::Reverse, + server: Some("default".to_string()), + epg_timeshift: None, + created_at: None, + exp_date: Some(1672705545), + max_connections: Some(1), + status: Some(ProxyUserStatus::Expired), + }, + ProxyUserCredentials { + username: "Test3".to_string(), + password: "Test".to_string(), + token: Some("Test".to_string()), + proxy: ProxyType::Reverse, + server: Some("default".to_string()), + epg_timeshift: None, + created_at: None, + exp_date: Some(1672705545), + max_connections: Some(1), + status: Some(ProxyUserStatus::Expired), + }, + ProxyUserCredentials { + username: "Test4".to_string(), + password: "Test".to_string(), + token: Some("Test".to_string()), + proxy: ProxyType::Reverse, + server: Some("default".to_string()), + epg_timeshift: None, + created_at: None, + exp_date: Some(1672705545), + max_connections: Some(1), + status: Some(ProxyUserStatus::Expired), + } + ] + }; + + let mut cfg = Config::default(); + let target_user = vec![user]; + cfg.t_config_path = temp_dir().to_string_lossy().to_string(); + let _ = store_api_user(&cfg, &target_user); + + let user_list = load_api_user(&cfg); + assert!(user_list.is_ok()); + assert_eq!(user_list.as_ref().unwrap().len(), 1); + assert_eq!(user_list.as_ref().unwrap().get(0).unwrap().credentials.len(), 4); + } } \ No newline at end of file diff --git a/src/utils/file/config_reader.rs b/src/utils/file/config_reader.rs index f2519573c..ed249ba4e 100644 --- a/src/utils/file/config_reader.rs +++ b/src/utils/file/config_reader.rs @@ -117,7 +117,7 @@ fn write_config_file(file_path: &str, backup_dir: &str, config: &T, default_n T: ?Sized + Serialize { let path = PathBuf::from(file_path); let filename = path.file_name().map_or(default_name.to_string(), |f| f.to_string_lossy().to_string()); - let backup_path = PathBuf::from(backup_dir).join(format!("{}_{}", filename, Local::now().format("%Y%m%d_%H%M%S"))); + let backup_path = PathBuf::from(backup_dir).join(format!("{filename}_{}", Local::now().format("%Y%m%d_%H%M%S"))); match std::fs::copy(&path, &backup_path) {