From fcc82dec74dce584d992838415840c0af408ec79 Mon Sep 17 00:00:00 2001 From: euzu Date: Fri, 7 Mar 2025 12:57:21 +0100 Subject: [PATCH] user boquet for m3u playlist --- frontend/public/i18n/en_common.json | 2 + frontend/src/api/playlist-api-service.ts | 2 +- frontend/src/api/user-config-api-service.ts | 18 +- .../playlist-browser/playlist-browser.tsx | 4 +- .../playlist-filter/playlist-filter.scss | 8 +- .../playlist-gallery/playlist-gallery.tsx | 12 +- .../user-playlist/user-playlist.scss | 81 +------- .../component/user-playlist/user-playlist.tsx | 189 +++++++----------- .../user-target-playlist.scss | 91 +++++++++ .../user-target-playlist.tsx | 162 +++++++++++++++ frontend/src/model/playlist.ts | 14 +- frontend/src/scss/_common.scss | 9 + frontend/src/service/file-service.ts | 16 +- frontend/src/service/user-config-service.ts | 8 +- src/api/endpoints/user_api.rs | 117 ++++++----- src/api/endpoints/v1_api.rs | 42 ++-- src/api/endpoints/xtream_api.rs | 6 +- src/model/playlist_categories.rs | 19 ++ src/model/xtream.rs | 9 + src/repository/m3u_playlist_iterator.rs | 7 +- src/repository/m3u_repository.rs | 48 +++-- src/repository/user_repository.rs | 109 ++++++---- src/repository/xtream_playlist_iterator.rs | 14 +- src/repository/xtream_repository.rs | 24 ++- 24 files changed, 649 insertions(+), 362 deletions(-) create mode 100755 frontend/src/component/user-target-playlist/user-target-playlist.scss create mode 100755 frontend/src/component/user-target-playlist/user-target-playlist.tsx diff --git a/frontend/public/i18n/en_common.json b/frontend/public/i18n/en_common.json index 7aa232c40..27a142e63 100644 --- a/frontend/public/i18n/en_common.json +++ b/frontend/public/i18n/en_common.json @@ -80,6 +80,7 @@ "PROVIDER": "Provider", "CUSTOM": "Custom", "XTREAM_CODES": "xtream codes", + "XC": "xc", "M3U": "m3u", "PLAYLIST_BROWSER": "Playlist Browser" }, @@ -87,6 +88,7 @@ "USER_BOUQUET_EDITOR": "User group editor" }, "MESSAGES": { + "NO_CONTENT": "No content", "INVALID_REGEXP": "Invalid regexp", "INVALID_FILETYPE": "Invalid filetype", "DOWNLOAD": { diff --git a/frontend/src/api/playlist-api-service.ts b/frontend/src/api/playlist-api-service.ts index 8d8d40332..8809a6861 100644 --- a/frontend/src/api/playlist-api-service.ts +++ b/frontend/src/api/playlist-api-service.ts @@ -1,7 +1,7 @@ import ApiService, {DefaultApiService} from "./api-service"; import {Observable, throwError} from "rxjs"; import {PlaylistRequest} from "../model/playlist-request"; -import {PlaylistCategories, PlaylistResponse} from "../model/playlist"; +import {PlaylistResponse} from "../model/playlist"; const PLAYLIST_API_PATH = 'playlist'; const TARGET_UPDATE_API_PATH = 'playlist/update'; diff --git a/frontend/src/api/user-config-api-service.ts b/frontend/src/api/user-config-api-service.ts index 25460fa4c..1cd8cf826 100644 --- a/frontend/src/api/user-config-api-service.ts +++ b/frontend/src/api/user-config-api-service.ts @@ -1,26 +1,26 @@ import {Observable} from "rxjs"; import ApiService, {DefaultApiService} from "./api-service"; -import {PlaylistCategories} from "../model/playlist"; +import {UserPlaylistCategories} from "../model/playlist"; const PLAYLIST_CATEGORIES_PATH = "user/playlist/categories"; const PLAYLIST_BOUQUET_PATH = "user/playlist/bouquet"; export default interface UserConfigApiService extends ApiService { - getPlaylistBouquet(): Observable; - getPlaylistCategories(): Observable; - savePlaylistBouquet(bouquet: PlaylistCategories): Observable; + getPlaylistBouquet(): Observable; + getPlaylistCategories(): Observable; + savePlaylistBouquet(bouquet: UserPlaylistCategories): Observable; } export class DefaultUserConfigApiService extends DefaultApiService implements UserConfigApiService { - getPlaylistBouquet(): Observable { - return this.get(PLAYLIST_BOUQUET_PATH); + getPlaylistBouquet(): Observable { + return this.get(PLAYLIST_BOUQUET_PATH); } - getPlaylistCategories(): Observable { - return this.get(PLAYLIST_CATEGORIES_PATH); + getPlaylistCategories(): Observable { + return this.get(PLAYLIST_CATEGORIES_PATH); } - savePlaylistBouquet(bouquet: PlaylistCategories): Observable { + savePlaylistBouquet(bouquet: UserPlaylistCategories): Observable { return this.post(PLAYLIST_BOUQUET_PATH, bouquet); } } diff --git a/frontend/src/component/playlist-browser/playlist-browser.tsx b/frontend/src/component/playlist-browser/playlist-browser.tsx index 1798ed30b..ff9530666 100644 --- a/frontend/src/component/playlist-browser/playlist-browser.tsx +++ b/frontend/src/component/playlist-browser/playlist-browser.tsx @@ -1,7 +1,7 @@ -import React, {useCallback, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import './playlist-browser.scss'; import SourceSelector from "..//source-selector/source-selector"; -import PlaylistViewer, {IPlaylistViewer, SearchRequest} from "../playlist-viewer/playlist-viewer"; +import PlaylistViewer, { SearchRequest} from "../playlist-viewer/playlist-viewer"; import {useSnackbar} from 'notistack'; import {useServices} from "../../provider/service-provider"; import Progress from '..//progress/progress'; diff --git a/frontend/src/component/playlist-filter/playlist-filter.scss b/frontend/src/component/playlist-filter/playlist-filter.scss index 938df69ef..301d60d70 100755 --- a/frontend/src/component/playlist-filter/playlist-filter.scss +++ b/frontend/src/component/playlist-filter/playlist-filter.scss @@ -16,13 +16,7 @@ } &__option-active { - box-sizing: border-box !important; - transform: scale(0.9); - background-color: var(--option-active-background-color) !important; - color: var(--option-active-color) !important; - svg { - color: var(--option-active-color) !important; - } + @include common.activeOptionButton(); } &__error-message { diff --git a/frontend/src/component/playlist-gallery/playlist-gallery.tsx b/frontend/src/component/playlist-gallery/playlist-gallery.tsx index 4799b5e7a..d05f97647 100644 --- a/frontend/src/component/playlist-gallery/playlist-gallery.tsx +++ b/frontend/src/component/playlist-gallery/playlist-gallery.tsx @@ -192,7 +192,7 @@ export default function PlaylistGallery(props: PlaylistGalleryProps) { } } } - }, [data, selectedCategory, selectedGroup]) + }, [data, selectedCategory, selectedGroup, selectedItem]) const handleCategorySelect = useCallback((evt: any) => { let category = evt.target.dataset.category; @@ -303,7 +303,7 @@ export default function PlaylistGallery(props: PlaylistGalleryProps) { if (nodes.length) { return
{nodes}
} - return
No Content
+ return
{translate("MESSAGES.NO_CONTENT")}
}, [translate, handleCategorySelect]); const renderSelectionContent = useCallback((): ReactNode => { @@ -316,9 +316,9 @@ export default function PlaylistGallery(props: PlaylistGalleryProps) { if (selectedCategory) { return renderGroups(selectedCategory, data[selectedCategory]); } - return
No Content
+ return
{translate("MESSAGES.NO_CONTENT")}
}, [data, selectedCategory, selectedGroup, selectedItem, - renderGroups, renderItems, renderItem]) + renderGroups, renderItems, renderItem, translate]) const renderContent = useCallback((): ReactNode => { if (data) { @@ -331,8 +331,8 @@ export default function PlaylistGallery(props: PlaylistGalleryProps) { return renderCategories(data); } } - return
No Content
; - }, [data, selectedCategory, selectedGroup, selectedItem, renderCategories, renderSelectionContent]); + return
{translate("MESSAGES.NO_CONTENT")}
; + }, [data, selectedCategory, selectedGroup, selectedItem, renderCategories, renderSelectionContent, translate]); const handleBreadcrumb = useCallback((evt: any) => { const index = evt.target.dataset.index; diff --git a/frontend/src/component/user-playlist/user-playlist.scss b/frontend/src/component/user-playlist/user-playlist.scss index 4d27d12c4..a3d1d7db8 100755 --- a/frontend/src/component/user-playlist/user-playlist.scss +++ b/frontend/src/component/user-playlist/user-playlist.scss @@ -7,89 +7,26 @@ @include preferences.prefsToolbar(); - &__header { - font-size: 2rem; - color: var(--label-color); - } - &__categories-panel { - @include common.container_vert_no_padding(); - overflow: hidden; - } - &__categories-toolbar { + &__content { @include common.container_horz_no_padding(); - border: 1px solid red; - } - - &__categories { - display: grid; - grid-gap: 10px; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - overflow-x: hidden; - overflow-y: auto; - gap: 8px; - padding: 4px; - - &__toolbar { - display: flex; - flex-flow: row nowrap; - gap: 4px; - flex: 0 0 auto; - padding: 4px; - background-color: var(--background-color); - align-items: center; - - @media (max-width: common.$media-size-mobile) { - flex-wrap: wrap-reverse; - gap: 8px; - } - - &-filter { - display: flex; - flex: 1 1 auto; - background-color: inherit; - .playlist-filter { - input { - font-size: 1em; - } - } - } + overflow: hidden; + &-toolbar { + @include common.container_vert_no_padding(); button { @include common.iconButton(); height: 48px; } .button-active { - box-sizing: border-box !important; - transform: scale(0.9); - background-color: var(--option-active-background-color) !important; - svg { - color: var(--option-active-color) !important; - } + @include common.activeOptionButton(); } } - &__category { - border:1px solid var(--border-color); - padding: 12px 8px; - border-radius: var(--border-radius); - background-color: var(--card-background-color); - box-sizing: border-box; - display: flex; - flex-flow: row nowrap; - gap: 8px; - white-space: nowrap; - - .checkbox-container { - width: 100%; - overflow: hidden; - text-overflow: ellipsis; - } - - .checkbox-container:hover { - cursor: pointer; - color: var(--label-color); - } + &-panels { + @include common.container_vert_no_padding(); + flex: 1 1 auto; + overflow: hidden; } } } \ No newline at end of file diff --git a/frontend/src/component/user-playlist/user-playlist.tsx b/frontend/src/component/user-playlist/user-playlist.tsx index ac59ce4a7..69a8bfa8a 100755 --- a/frontend/src/component/user-playlist/user-playlist.tsx +++ b/frontend/src/component/user-playlist/user-playlist.tsx @@ -1,20 +1,16 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import './user-playlist.scss'; import {useServices} from "../../provider/service-provider"; import {finalize, first, zip} from 'rxjs'; -import {PlaylistCategories, PlaylistGroup} from "../../model/playlist"; +import {UserPlaylistCategories, UserPlaylistTargetCategories} from "../../model/playlist"; import LoadingIndicator from '../loading-indicator/loading-indicator'; -import TabSet from "../tab-set/tab-set"; -import Checkbox from "../checkbox/checkbox"; -import {enqueueSnackbar} from "notistack"; -import {getIconByName} from "../../icons/icons"; -import PlaylistFilter from "../playlist-filter/playlist-filter"; import useTranslator from "../../hook/use-translator"; +import UserTargetPlaylist, {BouquetSelection} from "../user-target-playlist/user-target-playlist"; +import {useSnackbar} from "notistack"; -const CATEGORY_TABS = [ - {label: "LABEL.LIVE", key: "live"}, - {label: "LABEL.VOD", key: "vod"}, - {label: "LABEL.SERIES", key: "series"} +const TARGET_TABS = [ + {label: "LABEL.XC", key: "xtream"}, + {label: "LABEL.M3U", key: "m3u"}, ]; function isEmpty(value: any): boolean { @@ -25,13 +21,6 @@ function isEmpty(value: any): boolean { return false; } -const selectEntries = (selected: boolean, list: PlaylistGroup[]): Record => { - return list?.reduce((acc: Record, category: PlaylistGroup) => { - acc[category.id] = selected; - return acc; - }, {}); -} - /* eslint-disable @typescript-eslint/no-empty-interface */ interface UserPlaylistProps { @@ -41,67 +30,76 @@ export default function UserPlaylist(props: UserPlaylistProps) { const services = useServices(); const translate = useTranslator(); const [loading, setLoading] = useState(false); - const [categories, setCategories] = useState(undefined); - const [filteredCategories, setFilteredCategories] = useState({} as any); - const [selections, setSelections] = useState>({}); - const [activeTab, setActiveTab] = useState(CATEGORY_TABS[0].key); - const [showSelected, setShowSelected] = useState(false); - const tabs = useMemo(() => CATEGORY_TABS.map(d => ({key: d.key, label: translate(d.label) })), [translate]) - - const getActiveCategories = useCallback((key: string) => { - let active = ((filteredCategories as any)?.[key] ?? (categories as any)?.[key]) as any; - if (showSelected) { - return active.filter((c: PlaylistGroup) => selections[c.id]) - } - return active; - }, - [categories, filteredCategories, showSelected, selections]); + const [categories, setCategories] = useState(undefined); + const [bouquets, setBouquets] = useState(undefined); + const [activeTab, setActiveTab] = useState(TARGET_TABS[0].key); + const selectionRef = useRef<{ xtream: BouquetSelection, m3u: BouquetSelection }>({} as any); + const {enqueueSnackbar/*, closeSnackbar*/} = useSnackbar(); useEffect(() => { setLoading(true); - zip(services.userConfig().getPlaylistBouquet().pipe(first()) , + zip(services.userConfig().getPlaylistBouquet().pipe(first()), services.userConfig().getPlaylistCategories().pipe(first())).pipe(finalize(() => setLoading(false))) - .subscribe(([bouquet, categories]: [PlaylistCategories, PlaylistCategories]) => { + .subscribe(([bouquet, categories]: [UserPlaylistCategories, UserPlaylistCategories]) => { if (isEmpty(bouquet)) { bouquet = undefined; } if (isEmpty(categories)) { - categories = {} as any; + categories = undefined; } - Object.values(categories).forEach((list: PlaylistGroup[]) => { - list.sort((a, b) => a.name.localeCompare(b.name, {sensitivity: 'base'} as any)) - }); - if (bouquet || categories) { - const user_bouquet: any = {} - CATEGORY_TABS.map(t => t.key).forEach(key => { - let current_bouquet: PlaylistGroup[] = ((bouquet as any)?.[key]?.length ? (bouquet as any)[key] : (categories as any)?.[key]) ?? []; - Object.values(current_bouquet).forEach((c: PlaylistGroup) => user_bouquet[c.id] = true); - }); - setSelections(user_bouquet); + setCategories(categories); + setBouquets(bouquet); + + const mapToSelection = (userBouquet: string[]) => { + if (userBouquet) { + return userBouquet.reduce((acc: any, e: string) => { + acc[e] = true; + return acc; + }, {}) + } + return undefined; } - setCategories(categories ?? undefined); + const mapTargetToSelection = (targetBouquet: UserPlaylistTargetCategories) => { + return { + live: mapToSelection(targetBouquet?.live), + vod: mapToSelection(targetBouquet?.vod), + series: mapToSelection(targetBouquet?.series), + } + } + + selectionRef.current = { + xtream: mapTargetToSelection(bouquet?.xtream), + m3u: mapTargetToSelection(bouquet?.m3u), + }; }); }, [services]); - const handleCheckboxChange = useCallback((value: string, checked:boolean) => { - setSelections(selections => ({...selections, [value]: checked})); + const handleActiveTabChange = useCallback((event: any) => { + const tab = event.target.dataset.tab; + setActiveTab(tab); }, []); - const renderCat = useCallback((cat:PlaylistGroup) => { - return
- -
; - }, [handleCheckboxChange, selections]); - const handleSave = useCallback(() => { setLoading(true); - const live = categories?.live?.filter(c => selections[c.id]); - const vod = categories?.vod?.filter(c => selections[c.id]); - const series = categories?.series?.filter(c => selections[c.id]); - const bouquet: PlaylistCategories = {live, series, vod}; + + const toClusterCategories = (clusterBouquet: any, clusterCategories: any): string[] => { + const result = clusterBouquet ? Object.keys(clusterBouquet).filter(key => clusterBouquet[key]) : undefined; + if (result?.length === clusterCategories?.length) { + return undefined; + } + return result; + } + const toTargetCategories = (bs: BouquetSelection, targetCategories: UserPlaylistTargetCategories): UserPlaylistTargetCategories => ({ + live: toClusterCategories(bs?.live, targetCategories?.live), + vod: toClusterCategories(bs?.vod, targetCategories?.vod), + series: toClusterCategories(bs?.series, targetCategories?.series) + }); + + const bouquet: UserPlaylistCategories = { + xtream: toTargetCategories(selectionRef.current.xtream, categories.xtream), + m3u: toTargetCategories(selectionRef.current.m3u, categories.m3u) + } + services.userConfig().savePlaylistBouquet(bouquet).pipe(first(), finalize(() => setLoading(false))).subscribe({ next: () => { enqueueSnackbar(translate('MESSAGES.SAVE.BOUQUET.SUCCESS'), {variant: 'success'}) @@ -110,43 +108,11 @@ export default function UserPlaylist(props: UserPlaylistProps) { enqueueSnackbar(translate('MESSAGES.SAVE.BOUQUET.FAIL'), {variant: 'error'}) } }) - }, [services, categories, selections, translate]); + }, [services, translate, enqueueSnackbar, categories?.m3u, categories?.xtream]); - const handleSelectAll = useCallback(() => { - let activeCategories = getActiveCategories(activeTab); - if (activeCategories?.length) { - setSelections(selections => ({...selections, ...selectEntries(true, activeCategories)})); - } - }, [activeTab, getActiveCategories]); - - const handleDeselectAll = useCallback(() => { - let activeCategories = getActiveCategories(activeTab); - if (activeCategories?.length) { - setSelections(selections => ({...selections, ...selectEntries(false, activeCategories)})); - } - }, [activeTab, getActiveCategories]); - - const handleFilter = useCallback((filter: string, regexp: boolean): void => { - let filter_value = regexp ? filter : filter.toLowerCase(); - if (filter_value?.length) { - const filtered = (categories as any)?.[activeTab]?.filter((cat: PlaylistGroup) => { - if (regexp) { - // eslint-disable-next-line eqeqeq - return cat.name.trim().match(filter_value) != undefined; - } else { - return (cat.name.trim().toLowerCase().indexOf(filter_value) > -1); - } - }) ?? []; - setFilteredCategories(filteredCategories => ({...filteredCategories, [activeTab]: filtered})); - } else { - setFilteredCategories(filteredCategories => ({...filteredCategories, [activeTab]: (categories as any)?.[activeTab]})); - } - }, [activeTab, categories]); - - const handleShowSelected = useCallback((event: any) => { - event.target.blur(); - setShowSelected(!showSelected); - }, [showSelected]); + const handleSelectionChange = (selections: BouquetSelection) => { + (selectionRef.current as any)[activeTab] = selections; + } return <> @@ -155,21 +121,20 @@ export default function UserPlaylist(props: UserPlaylistProps) { - - {CATEGORY_TABS.map(tab =>
-
-
- -
- - - +
+
+ {TARGET_TABS.map((t) => + )}
-
- {getActiveCategories(tab.key)?.map(renderCat)} -
-
) - } +
+ {TARGET_TABS.map((t) => + )} +
+
; } diff --git a/frontend/src/component/user-target-playlist/user-target-playlist.scss b/frontend/src/component/user-target-playlist/user-target-playlist.scss new file mode 100755 index 000000000..ecf9b31aa --- /dev/null +++ b/frontend/src/component/user-target-playlist/user-target-playlist.scss @@ -0,0 +1,91 @@ +@use '../../scss/common'; +@use '../../scss/preferences'; + +.user-target-playlist { + @include common.container_vert(); + overflow: hidden; + + @include preferences.prefsToolbar(); + + &__header { + font-size: 2rem; + color: var(--label-color); + } + &__categories-panel { + @include common.container_vert_no_padding(); + overflow: hidden; + } + + &__categories-toolbar { + @include common.container_horz_no_padding(); + } + + &__categories { + display: grid; + grid-gap: 10px; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + overflow-x: hidden; + overflow-y: auto; + gap: 8px; + padding: 4px; + + &__toolbar { + display: flex; + flex-flow: row nowrap; + gap: 4px; + flex: 0 0 auto; + padding: 4px; + background-color: var(--background-color); + align-items: center; + + @media (max-width: common.$media-size-mobile) { + flex-wrap: wrap-reverse; + gap: 8px; + } + + &-filter { + display: flex; + flex: 1 1 auto; + background-color: inherit; + .playlist-filter { + input { + font-size: 1em; + } + } + } + + button { + @include common.iconButton(); + height: 48px; + } + + .button-active { + @include common.activeOptionButton(); + text-orientation: upright; + } + } + + &__category { + border:1px solid var(--border-color); + padding: 12px 8px; + border-radius: var(--border-radius); + background-color: var(--card-background-color); + box-sizing: border-box; + display: flex; + flex-flow: row nowrap; + gap: 8px; + white-space: nowrap; + + .checkbox-container { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + } + + .checkbox-container:hover { + cursor: pointer; + color: var(--label-color); + } + } + } +} \ No newline at end of file diff --git a/frontend/src/component/user-target-playlist/user-target-playlist.tsx b/frontend/src/component/user-target-playlist/user-target-playlist.tsx new file mode 100755 index 000000000..ee2a5a2bc --- /dev/null +++ b/frontend/src/component/user-target-playlist/user-target-playlist.tsx @@ -0,0 +1,162 @@ +import React, {useCallback, useEffect, useState} from 'react'; +import './user-target-playlist.scss'; +import {UserPlaylistCategories, UserPlaylistTargetCategories} from "../../model/playlist"; +import TabSet from "../tab-set/tab-set"; +import Checkbox from "../checkbox/checkbox"; +import {getIconByName} from "../../icons/icons"; +import PlaylistFilter from "../playlist-filter/playlist-filter"; +import useTranslator from "../../hook/use-translator"; + +const CATEGORY_TABS = [ + {label: "LABEL.LIVE", key: "live"}, + {label: "LABEL.VOD", key: "vod"}, + {label: "LABEL.SERIES", key: "series"} +]; + +export interface BouquetSelection { + live: Record, + vod: Record, + series: Record, +} + +const selectEntries = (selected: boolean, list: string[]): Record => { + return list?.reduce((acc: Record, category: string) => { + acc[category] = selected; + return acc; + }, {}); +} + +interface UserTargetPlaylistProps { + visible: boolean; + categories: UserPlaylistTargetCategories; + bouquet: UserPlaylistTargetCategories; + onSelectionChange: (selections: BouquetSelection) => void, +} + +export default function UserTargetPlaylist(props: UserTargetPlaylistProps) { + const {categories, bouquet, visible, onSelectionChange} = props; + const translate = useTranslator(); + const [filteredCategories, setFilteredCategories] = useState({} as any); + const [selections, setSelections] = useState({} as any); + const [activeTab, setActiveTab] = useState(CATEGORY_TABS[0].key); + const [showSelected, setShowSelected] = useState(false); + + useEffect(() => { + if (categories) { + Object.values(categories).forEach((list: string[]) => { + list.sort((a, b) => a.localeCompare(b, {sensitivity: 'base'} as any)) + }); + const mapToSelection = (userBouquet: any, cluster: string) => { + let clusterBouquet = userBouquet?.[cluster]; + return (categories as any)[cluster].reduce((acc: any, e: string) => { + acc[e] = clusterBouquet ? clusterBouquet?.indexOf(e) >= 0 : true + return acc; + }, {}) + } + const user_bouquets = { + live: mapToSelection(bouquet, 'live'), + vod: mapToSelection(bouquet, 'vod'), + series: mapToSelection(bouquet,'series'), + }; + setSelections(user_bouquets); + } + }, [bouquet, categories]) + + const getActiveCategories = useCallback((key: string) => { + let active = ((filteredCategories as any)?.[key] ?? (categories as any)?.[key]) as any; + if (showSelected) { + return active.filter((c: string) => (selections as any)[key]?.[c]) + } + return active; + }, + [categories, filteredCategories, showSelected, selections]); + + + const handleCheckboxChange = useCallback((value: string, checked: boolean) => { + let clusterSelections = (selections as any)[activeTab] as any; + if (!clusterSelections) { + clusterSelections = {}; + (selections as any)[activeTab] = clusterSelections as any; + } + clusterSelections[value] = checked; + let newSelections = ({...selections, [activeTab]: clusterSelections}); + onSelectionChange(newSelections) + setSelections(newSelections); + }, [activeTab, selections, onSelectionChange]); + + const renderCat = useCallback((cat: string) => { + const clusterSelections = (selections as any)[activeTab]; + // eslint-disable-next-line eqeqeq + const selected = clusterSelections?.[cat] === true; + return
+ +
; + }, [handleCheckboxChange, selections, activeTab]); + + const toggleSelection = (selected: boolean) => { + let activeCategories = getActiveCategories(activeTab); + if (activeCategories?.length) { + const newSelections: any = {...selections, [activeTab]: selectEntries(selected, activeCategories)}; + onSelectionChange(newSelections); + setSelections(newSelections); + } + }; + + const handleFilter = useCallback((filter: string, regexp: boolean): void => { + let filter_value = regexp ? filter : filter.toLowerCase(); + if (filter_value?.length) { + const filtered = (categories as any)?.[activeTab]?.filter((cat: string) => { + if (regexp) { + // eslint-disable-next-line eqeqeq + return cat.trim().match(filter_value) != undefined; + } else { + return (cat.trim().toLowerCase().indexOf(filter_value) > -1); + } + }) ?? []; + setFilteredCategories(filteredCategories => ({...filteredCategories, [activeTab]: filtered})); + } else { + setFilteredCategories(filteredCategories => ({ + ...filteredCategories, + [activeTab]: (categories as any)?.[activeTab] + })); + } + }, [activeTab, categories]); + + const handleShowSelected = useCallback((event: any) => { + event.target.blur(); + setShowSelected(!showSelected); + }, [showSelected]); + + const tabs = CATEGORY_TABS.filter(tab => (categories as any)?.[tab.key]?.length); + if (tabs.length === 0) { + return
+ {translate("MESSAGES.NO_CONTENT")} +
+ } + return <> +
+ + {tabs.map(tab =>
+
+
+ +
+ + + +
+
+ {getActiveCategories(tab.key)?.map(renderCat)} +
+
) + } +
+ ; +} diff --git a/frontend/src/model/playlist.ts b/frontend/src/model/playlist.ts index 3832a4dd8..c67375bab 100755 --- a/frontend/src/model/playlist.ts +++ b/frontend/src/model/playlist.ts @@ -78,4 +78,16 @@ export interface PlaylistResponse { channels: PlaylistChannels, } -export const EmptyPlaylistCategories: PlaylistCategories = {live: [], vod: [], series: []} \ No newline at end of file +export const EmptyPlaylistCategories: PlaylistCategories = {live: [], vod: [], series: []} + + +export interface UserPlaylistTargetCategories { + live: string[], + vod: string[], + series: string[], +} + +export interface UserPlaylistCategories { + xtream: UserPlaylistTargetCategories, + m3u: UserPlaylistTargetCategories, +} \ No newline at end of file diff --git a/frontend/src/scss/_common.scss b/frontend/src/scss/_common.scss index ef97ab865..9f454a14a 100644 --- a/frontend/src/scss/_common.scss +++ b/frontend/src/scss/_common.scss @@ -36,6 +36,15 @@ $media-size-mobile: 600px; @include container_common($justify-content, $padding: 0); } +@mixin activeOptionButton { + box-sizing: border-box !important; + background-color: var(--option-active-background-color) !important; + color: var(--option-active-color) !important; + font-weight: bold; + svg { + color: var(--option-active-color) !important; + } +} @mixin iconButton { display: flex; diff --git a/frontend/src/service/file-service.ts b/frontend/src/service/file-service.ts index e80cc1ec0..2efc85cff 100755 --- a/frontend/src/service/file-service.ts +++ b/frontend/src/service/file-service.ts @@ -1,8 +1,6 @@ -import {PlaylistItem, PlaylistGroup} from "../model/playlist"; -import FileSaver from "file-saver"; import {Observable, Subject, Subscription, tap} from "rxjs"; -import FileApiService, {DefaultFileApiService} from "../api/file-api-service"; import {DownloadInfo, FileDownloadInfo, FileDownloadRequest} from "../model/file-download"; +import FileApiService, { DefaultFileApiService } from "../api/file-api-service"; export default class FileService { @@ -17,18 +15,6 @@ export default class FileService { private notifyDownload(info: FileDownloadInfo) { this.downloadNotification.next(info); } - // - // save(playlist: PlaylistGroup[]) { - // const lines = ['#EXTM3U']; - // playlist.forEach(group => { - // group.channels.forEach((entry: PlaylistItem) => { - // lines.push(entry.header.source); - // lines.push(entry.header.url); - // }) - // }); - // const blob = new Blob([lines.join('\n')], { type: "text/plain;charset=utf-8" }); - // FileSaver.saveAs(blob, "playlist.m3u"); - // } download(req: FileDownloadRequest): Observable { return this.fileApiService.download(req).pipe(tap((result) => this.notifyDownload(result) )); diff --git a/frontend/src/service/user-config-service.ts b/frontend/src/service/user-config-service.ts index 4739c1c23..2e2ddca94 100644 --- a/frontend/src/service/user-config-service.ts +++ b/frontend/src/service/user-config-service.ts @@ -1,19 +1,19 @@ import { Observable } from "rxjs"; import UserConfigApiService, {DefaultUserConfigApiService} from "../api/user-config-api-service"; -import {PlaylistCategories} from "../model/playlist"; +import {UserPlaylistCategories} from "../model/playlist"; export default class UserConfigService { constructor(private userConfigApiService: UserConfigApiService = new DefaultUserConfigApiService()) { } - getPlaylistBouquet(): Observable { + getPlaylistBouquet(): Observable { return this.userConfigApiService.getPlaylistBouquet(); } - getPlaylistCategories(): Observable { + getPlaylistCategories(): Observable { return this.userConfigApiService.getPlaylistCategories(); } - savePlaylistBouquet(bouquet: PlaylistCategories): Observable { + savePlaylistBouquet(bouquet: UserPlaylistCategories): Observable { return this.userConfigApiService.savePlaylistBouquet(bouquet); } } diff --git a/src/api/endpoints/user_api.rs b/src/api/endpoints/user_api.rs index d428277c5..ac2112b41 100644 --- a/src/api/endpoints/user_api.rs +++ b/src/api/endpoints/user_api.rs @@ -1,41 +1,47 @@ -use std::sync::Arc; use crate::api::api_utils::{get_user_target_by_username, get_username_from_auth_header}; use crate::api::model::app_state::AppState; use crate::auth::authenticator::validator_user; -use crate::model::config::TargetType; -use crate::model::playlist_categories::PlaylistCategoriesDto; +use crate::model::config::{Config, ConfigTarget, TargetType}; +use crate::model::playlist::XtreamCluster; +use crate::model::playlist_categories::PlaylistBouquetDto; +use crate::model::xtream::PlaylistXtreamCategory; use crate::repository::user_repository::{load_user_bouquet_as_json, save_user_bouquet}; -use crate::repository::xtream_repository; +use crate::repository::{m3u_repository}; use actix_web::body::BodyStream; use actix_web::middleware::Compress; use actix_web::{web, HttpResponse}; use actix_web_httpauth::extractors::bearer::BearerAuth; use actix_web_httpauth::middleware::HttpAuthentication; use bytes::Bytes; -use futures::stream; -use serde::{Deserialize, Serialize}; -use std::io::Error; -use std::path::PathBuf; +use futures::{stream, StreamExt}; use log::error; +use std::collections::HashSet; +use std::sync::Arc; +use crate::repository::xtream_repository::xtream_get_playlist_categories; -#[derive(Deserialize, Serialize)] -struct PlaylistXtreamCategory { - #[serde(alias = "category_id")] - pub id: String, - #[serde(alias = "category_name")] - pub name: String, +fn get_categories_from_xtream(categories: Option>) -> Vec { + let mut groups: Vec = Vec::new(); + if let Some(cats) = categories { + for category in cats { + groups.push(category.name.to_string()); + } + } + groups } -pub(crate) async fn get_categories_content(action: Result<(Option, Option), Error>) -> Option { - if let Ok((Some(file_path), _content)) = action { - if let Ok(content) = tokio::fs::read_to_string(&file_path).await { - // TODO deserialize like sax parser - if let Ok(categories) = serde_json::from_str::>(&content) { - return serde_json::to_string(&categories).ok(); + +fn get_categories_from_m3u_playlist(target: &ConfigTarget, config: &Arc) -> Vec { + let mut groups = Vec::new(); + if let Some((_guard, iter)) = m3u_repository::iter_raw_m3u_playlist(config, target) { + let mut unique_groups = HashSet::new(); + for (item, _has_next) in iter { + if !unique_groups.contains(item.group.as_str()) { + unique_groups.insert(item.group.to_string()); + groups.push(item.group.to_string()); } } } - None + groups } async fn playlist_categories( @@ -49,24 +55,44 @@ async fn playlist_categories( } let config = &app_state.config; let target_name = &target.name; - if target.has_output(&TargetType::Xtream) { - let live_categories = get_categories_content(xtream_repository::xtream_get_collection_path(config, target_name, xtream_repository::COL_CAT_LIVE)).await; - let vod_categories = get_categories_content(xtream_repository::xtream_get_collection_path(config, target_name, xtream_repository::COL_CAT_VOD)).await; - let series_categories = get_categories_content(xtream_repository::xtream_get_collection_path(config, target_name, xtream_repository::COL_CAT_SERIES)).await; - let json_stream = - stream::iter(vec![ - Ok::(Bytes::from(r#"{"live": "#.to_string())), - Ok::(Bytes::from(live_categories.unwrap_or("null".to_string()))), - Ok::(Bytes::from(r#", "vod": "#.to_string())), - Ok::(Bytes::from(vod_categories.unwrap_or("null".to_string()))), - Ok::(Bytes::from(r#", "series": "#.to_string())), - Ok::(Bytes::from(series_categories.unwrap_or("null".to_string()))), - Ok::(Bytes::from(r"}".to_string())), - ]); - return HttpResponse::Ok() - .content_type(mime::APPLICATION_JSON) - .body(BodyStream::new(json_stream)); - } else if target.has_output(&TargetType::M3u) {} + let xtream_stream = if target.has_output(&TargetType::Xtream) { + let live_categories = get_categories_from_xtream(xtream_get_playlist_categories(config, target_name, XtreamCluster::Live).await); + let vod_categories = get_categories_from_xtream(xtream_get_playlist_categories(config, target_name, XtreamCluster::Video).await); + let series_categories = get_categories_from_xtream(xtream_get_playlist_categories(config, target_name, XtreamCluster::Series).await); + stream::iter(vec![ + Ok::(Bytes::from(r#"{"live": "#)), + Ok::(Bytes::from(serde_json::to_string(&live_categories).unwrap_or("[]".to_string()))), + Ok::(Bytes::from(r#", "vod": "#.to_string())), + Ok::(Bytes::from(serde_json::to_string(&vod_categories).unwrap_or("[]".to_string()))), + Ok::(Bytes::from(r#", "series": "#)), + Ok::(Bytes::from(serde_json::to_string(&series_categories).unwrap_or("[]".to_string()))), + Ok::(Bytes::from(r"}")), + ]) + } else { + stream::iter(vec![Ok::(Bytes::from(r#"{"live":[],"vod":[],"series":[]}"#))]) + }; + + let m3u_stream = if target.has_output(&TargetType::M3u) { + let live_categories = get_categories_from_m3u_playlist(target, config); + stream::iter(vec![ + Ok::(Bytes::from(r#"{"live": "#)), + Ok::(Bytes::from(serde_json::to_string(&live_categories).unwrap_or("[]".to_string()))), + Ok::(Bytes::from(r#","vod":[],"series":[]}"#)), + ]) + } else { + stream::iter(vec![Ok::(Bytes::from(r#"{"live":[],"vod":[],"series":[]}"#))]) + }; + + let json_stream = stream::once(async { Ok::(Bytes::from(r#"{"xtream": "#)) }) + .chain(xtream_stream) + .chain(stream::once(async { Ok::(Bytes::from(r#", "m3u": "#)) })) + .chain(m3u_stream) + .chain(stream::once(async { Ok::(Bytes::from("}")) })); + + + return HttpResponse::Ok() + .content_type(mime::APPLICATION_JSON) + .body(BodyStream::new(json_stream)); } } HttpResponse::BadRequest().finish() @@ -75,17 +101,17 @@ async fn playlist_categories( async fn save_playlist_bouquet( credentials: Option, app_state: web::Data>, - req: web::Json, + req: web::Json, ) -> HttpResponse { if let Some(username) = get_username_from_auth_header(credentials, &app_state) { if let Some((user, _target)) = get_user_target_by_username(username.as_str(), &app_state).await { if !user.has_permissions(&app_state) { return HttpResponse::Forbidden().finish(); } - match save_user_bouquet(&app_state.config, &username, &req.0).await { + match save_user_bouquet(&app_state.config, &username, &req.0) { Ok(()) => { return HttpResponse::Ok().finish(); - }, + } Err(err) => { error!("Saving bouquet for {username} failed: {err}"); } @@ -104,9 +130,10 @@ async fn playlist_bouquet( if !user.has_permissions(&app_state) { return HttpResponse::Forbidden().finish(); } - if let Some(bouquet) = load_user_bouquet_as_json(&app_state.config, &username).await { - return HttpResponse::Ok().content_type(mime::APPLICATION_JSON).body(bouquet); - } + let xtream = load_user_bouquet_as_json(&app_state.config, &username, TargetType::Xtream).await; + let m3u = load_user_bouquet_as_json(&app_state.config, &username, TargetType::M3u).await; + return HttpResponse::Ok().content_type(mime::APPLICATION_JSON).body( + format!(r#"{{"xtream": {}, "m3u": {} }}"#, xtream.unwrap_or("null".to_string()), m3u.unwrap_or("null".to_string()))); } } HttpResponse::Ok().content_type(mime::APPLICATION_JSON).body("{}") diff --git a/src/api/endpoints/v1_api.rs b/src/api/endpoints/v1_api.rs index ba87d23db..7168c201a 100644 --- a/src/api/endpoints/v1_api.rs +++ b/src/api/endpoints/v1_api.rs @@ -1,4 +1,5 @@ use std::collections::HashSet; +use std::path::PathBuf; use std::sync::Arc; use actix_web::body::BodyStream; @@ -11,7 +12,7 @@ use log::error; use serde_json::json; use crate::api::endpoints::user_api::user_api_register; -use crate::api::endpoints::{download_api, user_api}; +use crate::api::endpoints::{download_api}; use crate::api::model::app_state::AppState; use crate::api::model::config::{ServerConfig, ServerInputConfig, ServerSourceConfig, ServerTargetConfig}; use crate::api::model::request::{PlaylistRequest, PlaylistRequestType}; @@ -20,6 +21,7 @@ use crate::m3u_filter_error::M3uFilterError; use crate::model::api_proxy::{ApiProxyConfig, ApiProxyServerInfo, TargetUser}; use crate::model::config::{validate_targets, Config, ConfigDto, ConfigInput, ConfigInputOptions, ConfigSource, ConfigTarget, InputType, TargetType}; use crate::model::playlist::{XtreamCluster}; +use crate::model::xtream::PlaylistXtreamCategory; use crate::processing::processor::playlist; use crate::repository::user_repository::store_api_user; use crate::repository::xtream_repository; @@ -208,13 +210,25 @@ async fn get_playlist(client: Arc, cfg_input: Option<&ConfigInp +async fn get_categories_content(action: Result<(Option, Option), std::io::Error>) -> Option { + if let Ok((Some(file_path), _content)) = action { + if let Ok(content) = tokio::fs::read_to_string(&file_path).await { + // TODO deserialize like sax parser + if let Ok(categories) = serde_json::from_str::>(&content) { + return serde_json::to_string(&categories).ok(); + } + } + } + None +} + async fn get_playlist_for_target(cfg_target: Option<&ConfigTarget>, cfg: &Arc) -> HttpResponse { if let Some(target) = cfg_target { let target_name = &target.name; if target.has_output(&TargetType::Xtream) { - let live_categories = user_api::get_categories_content(xtream_repository::xtream_get_collection_path(cfg, target_name, xtream_repository::COL_CAT_LIVE)).await; - let vod_categories = user_api::get_categories_content(xtream_repository::xtream_get_collection_path(cfg, target_name, xtream_repository::COL_CAT_VOD)).await; - let series_categories = user_api::get_categories_content(xtream_repository::xtream_get_collection_path(cfg, target_name, xtream_repository::COL_CAT_SERIES)).await; + let live_categories = get_categories_content(xtream_repository::xtream_get_collection_path(cfg, target_name, xtream_repository::COL_CAT_LIVE)).await; + let vod_categories = get_categories_content(xtream_repository::xtream_get_collection_path(cfg, target_name, xtream_repository::COL_CAT_VOD)).await; + let series_categories = get_categories_content(xtream_repository::xtream_get_collection_path(cfg, target_name, xtream_repository::COL_CAT_SERIES)).await; let live_channels = xtream_repository::iter_raw_xtream_playlist(cfg, target, XtreamCluster::Live); let vod_channels = xtream_repository::iter_raw_xtream_playlist(cfg, target, XtreamCluster::Video); @@ -226,19 +240,19 @@ async fn get_playlist_for_target(cfg_target: Option<&ConfigTarget>, cfg: &Arc(Bytes::from(r#"{"categories": {"live": "#.to_string())), - Ok::(Bytes::from(live_categories.unwrap_or("null".to_string()))), - Ok::(Bytes::from(r#", "vod": "#.to_string())), - Ok::(Bytes::from(vod_categories.unwrap_or("null".to_string()))), - Ok::(Bytes::from(r#", "series": "#.to_string())), - Ok::(Bytes::from(series_categories.unwrap_or("null".to_string()))), - Ok::(Bytes::from(r#"},"channels": {"live": ["#.to_string())), + Ok::(Bytes::from(r#"{"categories": {"live": "#)), + Ok::(Bytes::from(live_categories.unwrap_or("[]".to_string()))), + Ok::(Bytes::from(r#", "vod": "#)), + Ok::(Bytes::from(vod_categories.unwrap_or("[]".to_string()))), + Ok::(Bytes::from(r#", "series": "#)), + Ok::(Bytes::from(series_categories.unwrap_or("[]".to_string()))), + Ok::(Bytes::from(r#"},"channels": {"live": ["#)), ]).chain(live_stream).chain(stream::iter(vec![ - Ok::(Bytes::from(r#"], "vod": ["#.to_string())), + Ok::(Bytes::from(r#"], "vod": ["#)), ])).chain(vod_stream).chain(stream::iter(vec![ - Ok::(Bytes::from(r#"], "series": ["#.to_string())), + Ok::(Bytes::from(r#"], "series": ["#)), ])).chain(series_stream).chain(stream::iter(vec![ - Ok::(Bytes::from(r"]}}".to_string())), + Ok::(Bytes::from(r"]}}")), ])); return HttpResponse::Ok().content_type(mime::APPLICATION_JSON).body(BodyStream::new(json_stream)); } else if target.has_output(&TargetType::M3u) { diff --git a/src/api/endpoints/xtream_api.rs b/src/api/endpoints/xtream_api.rs index c88b28ce2..4e78a6c8a 100644 --- a/src/api/endpoints/xtream_api.rs +++ b/src/api/endpoints/xtream_api.rs @@ -514,9 +514,9 @@ async fn xtream_player_api_handle_content_action(config: &Config, target_name: & if let Some(file_path) = path { // load user bouquet let filter = match action { - ACTION_GET_LIVE_CATEGORIES => user_repository::user_get_bouquet_filter(config, &user.username, category_id, XtreamCluster::Live).await, - ACTION_GET_VOD_CATEGORIES => user_repository::user_get_bouquet_filter(config, &user.username, category_id, XtreamCluster::Video).await, - ACTION_GET_SERIES_CATEGORIES => user_repository::user_get_bouquet_filter(config, &user.username, category_id, XtreamCluster::Series).await, + ACTION_GET_LIVE_CATEGORIES => user_repository::user_get_bouquet_filter(config, target_name,&user.username, category_id, TargetType::Xtream, XtreamCluster::Live).await, + ACTION_GET_VOD_CATEGORIES => user_repository::user_get_bouquet_filter(config, target_name, &user.username, category_id, TargetType::Xtream, XtreamCluster::Video).await, + ACTION_GET_SERIES_CATEGORIES => user_repository::user_get_bouquet_filter(config, target_name, &user.username, category_id, TargetType::Xtream, XtreamCluster::Series).await, _ => None }; if let Some(flt) = filter { diff --git a/src/model/playlist_categories.rs b/src/model/playlist_categories.rs index 4b2466961..8c7b268b7 100644 --- a/src/model/playlist_categories.rs +++ b/src/model/playlist_categories.rs @@ -22,4 +22,23 @@ pub struct PlaylistCategoriesDto { pub vod: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub series: Option>, +} + + +#[derive(Debug, serde::Serialize, serde::Deserialize, Default)] +pub struct TargetBouquetDto { + #[serde(skip_serializing_if = "Option::is_none")] + pub live: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub vod: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub series: Option>, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize, Default)] +pub struct PlaylistBouquetDto { + #[serde(skip_serializing_if = "Option::is_none")] + pub xtream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub m3u: Option, } \ No newline at end of file diff --git a/src/model/xtream.rs b/src/model/xtream.rs index 4739ee8bb..e1df9b227 100644 --- a/src/model/xtream.rs +++ b/src/model/xtream.rs @@ -667,4 +667,13 @@ pub fn rewrite_doc_urls(resource_url: Option<&String>, document: &mut Map Option { diff --git a/src/repository/m3u_repository.rs b/src/repository/m3u_repository.rs index 475d09f72..34beede56 100644 --- a/src/repository/m3u_repository.rs +++ b/src/repository/m3u_repository.rs @@ -1,18 +1,19 @@ +use crate::m3u_filter_error::{create_m3u_filter_error,info_err}; +use crate::m3u_filter_error::{str_to_io_error, M3uFilterError, M3uFilterErrorKind}; +use crate::model::api_proxy::ProxyUserCredentials; +use crate::model::config::{Config, ConfigTarget}; +use crate::model::playlist::{M3uPlaylistItem, PlaylistGroup, PlaylistItem, PlaylistItemType}; +use crate::repository::indexed_document::{IndexedDocumentDirectAccess, IndexedDocumentIterator, IndexedDocumentWriter}; +use crate::repository::m3u_playlist_iterator::{M3uPlaylistM3uTextIterator}; +use crate::repository::storage::{get_target_storage_path, FILE_SUFFIX_DB, FILE_SUFFIX_INDEX}; +use crate::utils::file::file_lock_manager::FileReadGuard; +use crate::utils::file::file_utils; +use crate::utils::file::file_utils::file_writer; +use log::error; use std::fs::File; use std::io::{Error, Write}; use std::path::{Path, PathBuf}; -use log::error; - -use crate::m3u_filter_error::{create_m3u_filter_error}; -use crate::m3u_filter_error::{str_to_io_error, M3uFilterError, M3uFilterErrorKind}; -use crate::model::api_proxy::{ProxyUserCredentials}; -use crate::model::config::{Config, ConfigTarget}; -use crate::model::playlist::{M3uPlaylistItem, PlaylistGroup, PlaylistItem, PlaylistItemType}; -use crate::repository::indexed_document::{IndexedDocumentDirectAccess, IndexedDocumentWriter}; -use crate::repository::m3u_playlist_iterator::{M3uPlaylistIteratorText}; -use crate::repository::storage::{get_target_storage_path, FILE_SUFFIX_DB, FILE_SUFFIX_INDEX}; -use crate::utils::file::file_utils; -use crate::utils::file::file_utils::file_writer; +use std::sync::Arc; const FILE_M3U: &str = "m3u"; macro_rules! cant_write_result { @@ -84,12 +85,11 @@ pub async fn m3u_load_rewrite_playlist( cfg: &Config, target: &ConfigTarget, user: &ProxyUserCredentials, -) -> Result { - M3uPlaylistIteratorText::new(cfg, target, user) +) -> Result { + M3uPlaylistM3uTextIterator::new(cfg, target, user) } - -pub async fn m3u_get_item_for_stream_id(stream_id: u32, cfg: &Config, target: &ConfigTarget) -> Result { +pub async fn m3u_get_item_for_stream_id(stream_id: u32, cfg: &Config, target: &ConfigTarget) -> Result { if stream_id < 1 { return Err(str_to_io_error("id should start with 1")); } @@ -99,4 +99,18 @@ pub async fn m3u_get_item_for_stream_id(stream_id: u32, cfg: &Config, target: & let _file_lock = cfg.file_locks.read_lock(&m3u_path); IndexedDocumentDirectAccess::read_indexed_item::(&m3u_path, &idx_path, &stream_id) } -} \ No newline at end of file +} + +pub fn iter_raw_m3u_playlist(config: &Arc, target: &ConfigTarget) -> Option<(FileReadGuard, impl Iterator)> { + let target_path = get_target_storage_path(config, target.name.as_str())?; + let (m3u_path, idx_path) = m3u_get_file_paths(&target_path); + if !m3u_path.exists() || !idx_path.exists() { + return None; + } + let file_lock = config.file_locks.read_lock(&m3u_path); + match IndexedDocumentIterator::::new(&m3u_path, &idx_path) + .map_err(|err| info_err!(format!("Could not deserialize file {m3u_path:?} - {err}"))) { + Ok(reader) => Some((file_lock, reader)), + Err(_) => None + } +} diff --git a/src/repository/user_repository.rs b/src/repository/user_repository.rs index ee15ac213..4300ac5a9 100644 --- a/src/repository/user_repository.rs +++ b/src/repository/user_repository.rs @@ -1,14 +1,16 @@ use crate::model::api_proxy::{ProxyType, ProxyUserCredentials, ProxyUserStatus, TargetUser}; -use crate::model::config::Config; +use crate::model::config::{Config, TargetType}; use crate::model::playlist::XtreamCluster; -use crate::model::playlist_categories::{PlaylistCategoriesDto, PlaylistCategoryDto}; +use crate::model::playlist_categories::{PlaylistBouquetDto, TargetBouquetDto}; 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 std::collections::{HashMap, HashSet}; +use std::io::Error; use std::path::{Path, PathBuf}; use chrono::Local; +use crate::repository::xtream_repository::xtream_get_playlist_categories; const USER_LIVE_BOUQUET: &str = "live_bouquet.json"; const USER_VOD_BOUQUET: &str = "vod_bouquet.json"; @@ -154,56 +156,72 @@ fn ensure_user_storage_path(cfg: &Config, username: &str) -> Option { } } -pub fn user_get_live_bouquet_path(user_storage_path: &Path) -> PathBuf { - user_storage_path.join(PathBuf::from(USER_LIVE_BOUQUET)) +fn user_get_live_bouquet_path(user_storage_path: &Path, target: &TargetType) -> PathBuf { + user_storage_path.join(PathBuf::from(format!("{}_{USER_LIVE_BOUQUET}", target.to_string().to_lowercase()))) } -pub fn user_get_vod_bouquet_path(user_storage_path: &Path) -> PathBuf { - user_storage_path.join(PathBuf::from(USER_VOD_BOUQUET)) +fn user_get_vod_bouquet_path(user_storage_path: &Path, target: &TargetType) -> PathBuf { + user_storage_path.join(PathBuf::from(format!("{}_{USER_VOD_BOUQUET}", target.to_string().to_lowercase()))) } -pub fn user_get_series_bouquet_path(user_storage_path: &Path) -> PathBuf { - user_storage_path.join(PathBuf::from(USER_SERIES_BOUQUET)) +fn user_get_series_bouquet_path(user_storage_path: &Path, target: &TargetType) -> PathBuf { + user_storage_path.join(PathBuf::from(format!("{}_{USER_SERIES_BOUQUET}", target.to_string().to_lowercase()))) } -pub async fn save_user_bouquet(cfg: &Config, username: &str, bouquet: &PlaylistCategoriesDto) -> Result<(), std::io::Error> { +fn save_user_bouquet_for_target(storage_path: &Path, target: TargetType, bouquet: &TargetBouquetDto)-> Result<(), Error> { + json_write_documents_to_file(&user_get_live_bouquet_path(storage_path, &target), &bouquet.live)?; + json_write_documents_to_file(&user_get_vod_bouquet_path(storage_path, &target), &bouquet.vod)?; + json_write_documents_to_file(&user_get_series_bouquet_path(storage_path, &target), &bouquet.series)?; + Ok(()) +} + +pub fn save_user_bouquet(cfg: &Config, username: &str, bouquet: &PlaylistBouquetDto) -> Result<(), std::io::Error> { if let Some(storage_path) = ensure_user_storage_path(cfg, username) { - json_write_documents_to_file(&user_get_live_bouquet_path(&storage_path), &bouquet.live)?; - json_write_documents_to_file(&user_get_vod_bouquet_path(&storage_path), &bouquet.vod)?; - json_write_documents_to_file(&user_get_series_bouquet_path(&storage_path), &bouquet.series)?; + if let Some(xb) = &bouquet.xtream { + save_user_bouquet_for_target(&storage_path, TargetType::Xtream, xb)?; + } + if let Some(mb) = &bouquet.m3u { + save_user_bouquet_for_target(&storage_path, TargetType::M3u, mb)?; + } Ok(()) } else { Err(std::io::Error::new(std::io::ErrorKind::NotFound, format!("User config path not found for user {username}"))) } } -async fn load_user_bouquet_from_file(file: &Path) -> String { - tokio::fs::read_to_string(file).await.unwrap_or_else(|_err| "[]".to_string()) +async fn load_user_bouquet_from_file(file: &Path) -> Option { + tokio::fs::read_to_string(file).await.ok() } -pub async fn load_user_bouquet_as_json(cfg: &Config, username: &str) -> Option { +pub async fn load_user_bouquet_as_json(cfg: &Config, username: &str, target: TargetType) -> Option { if let Some(storage_path) = get_user_storage_path(cfg, username) { if storage_path.exists() { - let live = load_user_bouquet_from_file(&user_get_live_bouquet_path(&storage_path)).await; - let vod = load_user_bouquet_from_file(&user_get_vod_bouquet_path(&storage_path)).await; - let series = load_user_bouquet_from_file(&user_get_series_bouquet_path(&storage_path)).await; - return Some(format!(r#"{{"live": {live}, "vod": {vod}, "series": {series} }}"#)); + let live = load_user_bouquet_from_file(&user_get_live_bouquet_path(&storage_path, &target)).await; + let vod = load_user_bouquet_from_file(&user_get_vod_bouquet_path(&storage_path, &target)).await; + let series = load_user_bouquet_from_file(&user_get_series_bouquet_path(&storage_path, &target)).await; + return Some(format!(r#"{{"live": {}, "vod": {}, "series": {} }}"#, + live.unwrap_or("null".to_string()), + vod.unwrap_or("null".to_string()), + series.unwrap_or("null".to_string()), + )); } } None } -pub(crate) async fn user_get_cluster_bouquet(cfg: &Config, username: &str, cluster: XtreamCluster) -> Option> { +async fn user_get_cluster_bouquet(cfg: &Config, username: &str, target: &TargetType, cluster: XtreamCluster) -> Option> { if let Some(storage_path) = get_user_storage_path(cfg, username) { if storage_path.exists() { let content = load_user_bouquet_from_file(&match cluster { - XtreamCluster::Live => user_get_live_bouquet_path(&storage_path), - XtreamCluster::Video => user_get_vod_bouquet_path(&storage_path), - XtreamCluster::Series => user_get_series_bouquet_path(&storage_path), + XtreamCluster::Live => user_get_live_bouquet_path(&storage_path, target), + XtreamCluster::Video => user_get_vod_bouquet_path(&storage_path, target), + XtreamCluster::Series => user_get_series_bouquet_path(&storage_path, target), }).await; - if let Ok(bouquet) = serde_json::from_str::>(&content) { - if !bouquet.is_empty() { - return Some(bouquet); + if let Some(value) = content { + if let Ok(bouquet) = serde_json::from_str::>(&value) { + if !bouquet.is_empty() { + return Some(bouquet); + } } } } @@ -212,23 +230,24 @@ pub(crate) async fn user_get_cluster_bouquet(cfg: &Config, username: &str, clust } -pub(crate) async fn user_get_live_bouquet(cfg: &Config, username: &str) -> Option> { - user_get_cluster_bouquet(cfg, username, XtreamCluster::Live).await +pub(crate) async fn user_get_live_bouquet(cfg: &Config, username: &str, target: &TargetType) -> Option> { + user_get_cluster_bouquet(cfg, username, target, XtreamCluster::Live).await } -pub(crate) async fn user_get_vod_bouquet(cfg: &Config, username: &str) -> Option> { - user_get_cluster_bouquet(cfg, username, XtreamCluster::Video).await +pub(crate) async fn user_get_vod_bouquet(cfg: &Config, username: &str, target: &TargetType) -> Option> { + user_get_cluster_bouquet(cfg, username, target, XtreamCluster::Video).await } -pub(crate) async fn user_get_series_bouquet(cfg: &Config, username: &str) -> Option> { - user_get_cluster_bouquet(cfg, username, XtreamCluster::Series).await +pub(crate) async fn user_get_series_bouquet(cfg: &Config, username: &str, target: &TargetType) -> Option> { + user_get_cluster_bouquet(cfg, username, target, XtreamCluster::Series).await } -pub async fn user_get_bouquet_filter(config: &Config, username: &str, category_id: &str, cluster: XtreamCluster) -> Option> { +// TODO save user bouquet for xtream with category id, and not load it every time when playlist read +pub async fn user_get_bouquet_filter(config: &Config, target_name: &str, username: &str, category_id: &str, target: TargetType, cluster: XtreamCluster) -> Option> { let bouquet = match cluster { - XtreamCluster::Live => user_get_live_bouquet(config, username).await, - XtreamCluster::Video => user_get_vod_bouquet(config, username).await, - XtreamCluster::Series => user_get_series_bouquet(config, username).await, + XtreamCluster::Live => user_get_live_bouquet(config, username, &target).await, + XtreamCluster::Video => user_get_vod_bouquet(config, username, &target).await, + XtreamCluster::Series => user_get_series_bouquet(config, username, &target).await, }; let category_id = category_id.trim(); let mut filter = HashSet::new(); @@ -237,8 +256,22 @@ pub async fn user_get_bouquet_filter(config: &Config, username: &str, category_i } if let Some(bouquet_categories) = bouquet { if !bouquet_categories.is_empty() { - for c in bouquet_categories { - filter.insert(c.id); + if target == TargetType::Xtream { + if let Some(xtream_categories) = xtream_get_playlist_categories(config, target_name,cluster).await { + let map: HashMap = xtream_categories + .into_iter() + .map(|item| (item.name, item.id)) + .collect(); + for c in bouquet_categories { + if let Some(id) = map.get(&c) { + filter.insert(id.to_string()); + } + } + } + } else { + for c in bouquet_categories { + filter.insert(c); + } } } } diff --git a/src/repository/xtream_playlist_iterator.rs b/src/repository/xtream_playlist_iterator.rs index 2f81389a4..697f58f29 100644 --- a/src/repository/xtream_playlist_iterator.rs +++ b/src/repository/xtream_playlist_iterator.rs @@ -3,7 +3,7 @@ use log::error; use crate::m3u_filter_error::info_err; use crate::m3u_filter_error::{M3uFilterError, M3uFilterErrorKind}; use crate::model::api_proxy::{ProxyUserCredentials}; -use crate::model::config::{Config, ConfigTarget}; +use crate::model::config::{Config, ConfigTarget, TargetType}; use crate::model::playlist::{XtreamCluster, XtreamPlaylistItem}; use crate::model::xtream::XtreamMappingOptions; use crate::repository::indexed_document::{IndexedDocumentIterator}; @@ -41,8 +41,8 @@ impl XtreamPlaylistIterator { let options = XtreamMappingOptions::from_target_options(target.options.as_ref(), config); let server_info = config.get_user_server_info(user); - let category_id_str = if category_id == 0 { String::new() } else { category_id.to_string() }; - let filter = user_get_bouquet_filter(config, &user.username, &category_id_str, cluster).await; + let category_id_filter = if category_id == 0 { "".to_string() } else { category_id.to_string() }; + let filter = user_get_bouquet_filter(config, target.name.as_str(), &user.username, &category_id_filter, TargetType::Xtream, cluster).await; Ok(Self { reader, @@ -63,7 +63,7 @@ impl XtreamPlaylistIterator { return None; } if let Some(set) = &self.filter { - self.reader.find(|(pli, _has_next)| set.contains(&pli.category_id.to_string())) + self.reader.find(|(pli, _has_next)| set.contains(&pli.group.to_string()) || set.contains(&pli.category_id.to_string())) } else { self.reader.next() } @@ -79,11 +79,11 @@ impl Iterator for XtreamPlaylistIterator { } -pub struct XtreamPlaylistIteratorText { +pub struct XtreamPlaylistJsonIterator { inner: XtreamPlaylistIterator, } -impl XtreamPlaylistIteratorText { +impl XtreamPlaylistJsonIterator { pub async fn new( cluster: XtreamCluster, config: &Config, @@ -97,7 +97,7 @@ pub async fn new( } } -impl Iterator for XtreamPlaylistIteratorText { +impl Iterator for XtreamPlaylistJsonIterator { type Item = (String, bool); fn next(&mut self) -> Option { self.inner.get_next().map(|(pli, has_next)| (pli.to_doc(&self.inner.base_url, &self.inner.options, &self.inner.user).to_string(), has_next)) diff --git a/src/repository/xtream_repository.rs b/src/repository/xtream_repository.rs index 207692ef9..9d960f26b 100644 --- a/src/repository/xtream_repository.rs +++ b/src/repository/xtream_repository.rs @@ -15,7 +15,7 @@ use crate::m3u_filter_error::{M3uFilterError, M3uFilterErrorKind, str_to_io_erro use crate::model::api_proxy::{ProxyType, ProxyUserCredentials}; use crate::model::config::{Config, ConfigInput, ConfigTarget}; use crate::model::playlist::{PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, XtreamCluster, XtreamPlaylistItem}; -use crate::model::xtream::{rewrite_doc_urls, XtreamMappingOptions, XtreamSeriesEpisode, INFO_RESOURCE_PREFIX, INFO_RESOURCE_PREFIX_EPISODE, SEASON_RESOURCE_PREFIX}; +use crate::model::xtream::{rewrite_doc_urls, PlaylistXtreamCategory, XtreamMappingOptions, XtreamSeriesEpisode, INFO_RESOURCE_PREFIX, INFO_RESOURCE_PREFIX_EPISODE, SEASON_RESOURCE_PREFIX}; use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery, BPlusTreeUpdate}; use crate::repository::indexed_document::{IndexedDocumentDirectAccess, IndexedDocumentGarbageCollector, IndexedDocumentIterator, IndexedDocumentWriter}; use crate::repository::playlist_repository::get_target_id_mapping; @@ -25,7 +25,7 @@ use crate::utils::file::file_utils::open_readonly_file; use crate::utils::hash_utils::generate_playlist_uuid; use crate::utils::json_utils::{get_u32_from_serde_value, json_iter_array, json_write_documents_to_file}; use crate::utils::bincode_utils::{bincode_deserialize}; -use crate::repository::xtream_playlist_iterator::XtreamPlaylistIteratorText; +use crate::repository::xtream_playlist_iterator::XtreamPlaylistJsonIterator; pub static COL_CAT_LIVE: &str = "cat_live"; pub static COL_CAT_SERIES: &str = "cat_series"; @@ -432,8 +432,8 @@ pub async fn xtream_load_rewrite_playlist( target: &ConfigTarget, category_id: u32, user: &ProxyUserCredentials, -) -> Result { - XtreamPlaylistIteratorText::new(cluster, config, target, category_id, user).await +) -> Result { + XtreamPlaylistJsonIterator::new(cluster, config, target, category_id, user).await } pub fn xtream_write_series_info( @@ -984,4 +984,18 @@ where stream::once(async { Ok(Bytes::from("")) }).right_stream() } } -} \ No newline at end of file +} + +pub(crate) async fn xtream_get_playlist_categories(config: &Config, target_name: &str, cluster: XtreamCluster) -> Option> { + let path = xtream_get_collection_path(config, target_name, match cluster { + XtreamCluster::Live => COL_CAT_LIVE, + XtreamCluster::Video => COL_CAT_VOD, + XtreamCluster::Series => COL_CAT_SERIES, + }); + if let Ok((Some(file_path), _content)) = path { + if let Ok(content) = tokio::fs::read_to_string(&file_path).await { + return serde_json::from_str::>(&content).ok(); + } + } + None +}