user boquet for m3u playlist

This commit is contained in:
euzu
2025-03-08 05:43:13 +01:00
parent d41c59288e
commit fcc82dec74
24 changed files with 649 additions and 362 deletions
+2
View File
@@ -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": {
+1 -1
View File
@@ -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';
+9 -9
View File
@@ -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<PlaylistCategories>;
getPlaylistCategories(): Observable<PlaylistCategories>;
savePlaylistBouquet(bouquet: PlaylistCategories): Observable<void>;
getPlaylistBouquet(): Observable<UserPlaylistCategories>;
getPlaylistCategories(): Observable<UserPlaylistCategories>;
savePlaylistBouquet(bouquet: UserPlaylistCategories): Observable<void>;
}
export class DefaultUserConfigApiService extends DefaultApiService implements UserConfigApiService {
getPlaylistBouquet(): Observable<PlaylistCategories> {
return this.get<PlaylistCategories>(PLAYLIST_BOUQUET_PATH);
getPlaylistBouquet(): Observable<UserPlaylistCategories> {
return this.get<UserPlaylistCategories>(PLAYLIST_BOUQUET_PATH);
}
getPlaylistCategories(): Observable<PlaylistCategories> {
return this.get<PlaylistCategories>(PLAYLIST_CATEGORIES_PATH);
getPlaylistCategories(): Observable<UserPlaylistCategories> {
return this.get<UserPlaylistCategories>(PLAYLIST_CATEGORIES_PATH);
}
savePlaylistBouquet(bouquet: PlaylistCategories): Observable<void> {
savePlaylistBouquet(bouquet: UserPlaylistCategories): Observable<void> {
return this.post<void>(PLAYLIST_BOUQUET_PATH, bouquet);
}
}
@@ -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';
@@ -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 {
@@ -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 <div className="playlist-gallery__categories">{nodes}</div>
}
return <div>No Content</div>
return <div>{translate("MESSAGES.NO_CONTENT")}</div>
}, [translate, handleCategorySelect]);
const renderSelectionContent = useCallback((): ReactNode => {
@@ -316,9 +316,9 @@ export default function PlaylistGallery(props: PlaylistGalleryProps) {
if (selectedCategory) {
return renderGroups(selectedCategory, data[selectedCategory]);
}
return <div>No Content</div>
return <div>{translate("MESSAGES.NO_CONTENT")}</div>
}, [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 <div>No Content</div>;
}, [data, selectedCategory, selectedGroup, selectedItem, renderCategories, renderSelectionContent]);
return <div>{translate("MESSAGES.NO_CONTENT")}</div>;
}, [data, selectedCategory, selectedGroup, selectedItem, renderCategories, renderSelectionContent, translate]);
const handleBreadcrumb = useCallback((evt: any) => {
const index = evt.target.dataset.index;
@@ -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;
}
}
}
@@ -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<string, boolean> => {
return list?.reduce((acc: Record<string, boolean>, 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<PlaylistCategories>(undefined);
const [filteredCategories, setFilteredCategories] = useState<PlaylistCategories>({} as any);
const [selections, setSelections] = useState<Record<string, boolean>>({});
const [activeTab, setActiveTab] = useState<string>(CATEGORY_TABS[0].key);
const [showSelected, setShowSelected] = useState<boolean>(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<UserPlaylistCategories>(undefined);
const [bouquets, setBouquets] = useState<UserPlaylistCategories>(undefined);
const [activeTab, setActiveTab] = useState<string>(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 <div className={'user-playlist__categories__category'} key={cat.id} data-tooltip={cat.name}>
<Checkbox label={cat.name}
value={cat.id}
checked={selections[cat.id]}
onSelect={handleCheckboxChange}></Checkbox>
</div>;
}, [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 <>
<LoadingIndicator loading={loading}></LoadingIndicator>
@@ -155,21 +121,20 @@ export default function UserPlaylist(props: UserPlaylistProps) {
<label>{translate('TITLE.USER_BOUQUET_EDITOR')}</label>
<button data-tooltip='LABEL.SAVE' onClick={handleSave}>{translate('LABEL.SAVE')}</button>
</div>
<TabSet tabs={tabs} active={activeTab} onTabChange={setActiveTab}></TabSet>
{CATEGORY_TABS.map(tab => <div key={tab.key} className={'user-playlist__categories-panel' + (activeTab !== tab.key ? ' hidden' : '')}>
<div className={'user-playlist__categories__toolbar'}>
<div className={'user-playlist__categories__toolbar-filter'}>
<PlaylistFilter onFilter={handleFilter}></PlaylistFilter>
</div>
<button className={showSelected ? 'button-active': ''} data-tooltip='LABEL.SHOW_SELECTED' onClick={handleShowSelected}>{getIconByName('Checked')}</button>
<button data-tooltip='LABEL.SELECT_ALL' onClick={handleSelectAll}>{getIconByName('SelectAll')}</button>
<button data-tooltip='LABEL.DESELECT_ALL' onClick={handleDeselectAll}>{getIconByName('DeselectAll')}</button>
<div className="user-playlist__content">
<div className="user-playlist__content-toolbar">
{TARGET_TABS.map((t) =>
<button key={t.key} className={activeTab === t.key ? 'button-active' : ''} data-tab={t.key}
onClick={handleActiveTabChange}>{translate(t.label)}</button>)}
</div>
<div className={'user-playlist__categories'}>
{getActiveCategories(tab.key)?.map(renderCat)}
</div>
</div>)
}
<div className="user-playlist__content-panels">
{TARGET_TABS.map((t) =>
<UserTargetPlaylist onSelectionChange={handleSelectionChange}
key={t.key} visible={activeTab === t.key}
bouquet={(bouquets as any)?.[t.key]}
categories={(categories as any)?.[t.key]}></UserTargetPlaylist>)}
</div>
</div>
</div>
</>;
}
@@ -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);
}
}
}
}
@@ -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<string, boolean>,
vod: Record<string, boolean>,
series: Record<string, boolean>,
}
const selectEntries = (selected: boolean, list: string[]): Record<string, boolean> => {
return list?.reduce((acc: Record<string, boolean>, 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<UserPlaylistCategories>({} as any);
const [selections, setSelections] = useState<BouquetSelection>({} as any);
const [activeTab, setActiveTab] = useState<string>(CATEGORY_TABS[0].key);
const [showSelected, setShowSelected] = useState<boolean>(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 <div className={'user-target-playlist__categories__category'} key={cat} data-tooltip={cat}>
<Checkbox label={cat}
value={cat}
checked={selected}
onSelect={handleCheckboxChange}></Checkbox>
</div>;
}, [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 <div className={"user-target-playlist" + (visible ? '' : ' hidden')}>
{translate("MESSAGES.NO_CONTENT")}
</div>
}
return <>
<div className={"user-target-playlist" + (visible ? '' : ' hidden')}>
<TabSet tabs={tabs} active={activeTab} onTabChange={setActiveTab}></TabSet>
{tabs.map(tab => <div key={tab.key}
className={'user-target-playlist__categories-panel' + (activeTab !== tab.key ? ' hidden' : '')}>
<div className={'user-target-playlist__categories__toolbar'}>
<div className={'user-target-playlist__categories__toolbar-filter'}>
<PlaylistFilter onFilter={handleFilter}></PlaylistFilter>
</div>
<button className={showSelected ? 'button-active' : ''} data-tooltip='LABEL.SHOW_SELECTED'
onClick={handleShowSelected}>{getIconByName('Checked')}</button>
<button data-tooltip='LABEL.SELECT_ALL'
onClick={() => toggleSelection(true)}>{getIconByName('SelectAll')}</button>
<button data-tooltip='LABEL.DESELECT_ALL'
onClick={() => toggleSelection(false)}>{getIconByName('DeselectAll')}</button>
</div>
<div className={'user-target-playlist__categories'}>
{getActiveCategories(tab.key)?.map(renderCat)}
</div>
</div>)
}
</div>
</>;
}
+13 -1
View File
@@ -78,4 +78,16 @@ export interface PlaylistResponse {
channels: PlaylistChannels,
}
export const EmptyPlaylistCategories: PlaylistCategories = {live: [], vod: [], series: []}
export const EmptyPlaylistCategories: PlaylistCategories = {live: [], vod: [], series: []}
export interface UserPlaylistTargetCategories {
live: string[],
vod: string[],
series: string[],
}
export interface UserPlaylistCategories {
xtream: UserPlaylistTargetCategories,
m3u: UserPlaylistTargetCategories,
}
+9
View File
@@ -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;
+1 -15
View File
@@ -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<FileDownloadInfo> {
return this.fileApiService.download(req).pipe(tap((result) => this.notifyDownload(result) ));
+4 -4
View File
@@ -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<PlaylistCategories> {
getPlaylistBouquet(): Observable<UserPlaylistCategories> {
return this.userConfigApiService.getPlaylistBouquet();
}
getPlaylistCategories(): Observable<PlaylistCategories> {
getPlaylistCategories(): Observable<UserPlaylistCategories> {
return this.userConfigApiService.getPlaylistCategories();
}
savePlaylistBouquet(bouquet: PlaylistCategories): Observable<void> {
savePlaylistBouquet(bouquet: UserPlaylistCategories): Observable<void> {
return this.userConfigApiService.savePlaylistBouquet(bouquet);
}
}
+72 -45
View File
@@ -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<PlaylistXtreamCategory>>) -> Vec<String> {
let mut groups: Vec<String> = 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<PathBuf>, Option<String>), Error>) -> Option<String> {
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::<Vec<PlaylistXtreamCategory>>(&content) {
return serde_json::to_string(&categories).ok();
fn get_categories_from_m3u_playlist(target: &ConfigTarget, config: &Arc<Config>) -> Vec<String> {
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, String>(Bytes::from(r#"{"live": "#.to_string())),
Ok::<Bytes, String>(Bytes::from(live_categories.unwrap_or("null".to_string()))),
Ok::<Bytes, String>(Bytes::from(r#", "vod": "#.to_string())),
Ok::<Bytes, String>(Bytes::from(vod_categories.unwrap_or("null".to_string()))),
Ok::<Bytes, String>(Bytes::from(r#", "series": "#.to_string())),
Ok::<Bytes, String>(Bytes::from(series_categories.unwrap_or("null".to_string()))),
Ok::<Bytes, String>(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, String>(Bytes::from(r#"{"live": "#)),
Ok::<Bytes, String>(Bytes::from(serde_json::to_string(&live_categories).unwrap_or("[]".to_string()))),
Ok::<Bytes, String>(Bytes::from(r#", "vod": "#.to_string())),
Ok::<Bytes, String>(Bytes::from(serde_json::to_string(&vod_categories).unwrap_or("[]".to_string()))),
Ok::<Bytes, String>(Bytes::from(r#", "series": "#)),
Ok::<Bytes, String>(Bytes::from(serde_json::to_string(&series_categories).unwrap_or("[]".to_string()))),
Ok::<Bytes, String>(Bytes::from(r"}")),
])
} else {
stream::iter(vec![Ok::<Bytes, String>(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, String>(Bytes::from(r#"{"live": "#)),
Ok::<Bytes, String>(Bytes::from(serde_json::to_string(&live_categories).unwrap_or("[]".to_string()))),
Ok::<Bytes, String>(Bytes::from(r#","vod":[],"series":[]}"#)),
])
} else {
stream::iter(vec![Ok::<Bytes, String>(Bytes::from(r#"{"live":[],"vod":[],"series":[]}"#))])
};
let json_stream = stream::once(async { Ok::<Bytes, String>(Bytes::from(r#"{"xtream": "#)) })
.chain(xtream_stream)
.chain(stream::once(async { Ok::<Bytes, String>(Bytes::from(r#", "m3u": "#)) }))
.chain(m3u_stream)
.chain(stream::once(async { Ok::<Bytes, String>(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<BearerAuth>,
app_state: web::Data<Arc<AppState>>,
req: web::Json<PlaylistCategoriesDto>,
req: web::Json<PlaylistBouquetDto>,
) -> 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("{}")
+28 -14
View File
@@ -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<reqwest::Client>, cfg_input: Option<&ConfigInp
async fn get_categories_content(action: Result<(Option<PathBuf>, Option<String>), std::io::Error>) -> Option<String> {
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::<Vec<PlaylistXtreamCategory>>(&content) {
return serde_json::to_string(&categories).ok();
}
}
}
None
}
async fn get_playlist_for_target(cfg_target: Option<&ConfigTarget>, cfg: &Arc<Config>) -> 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<Co
let json_stream =
stream::iter(vec![
Ok::<Bytes, String>(Bytes::from(r#"{"categories": {"live": "#.to_string())),
Ok::<Bytes, String>(Bytes::from(live_categories.unwrap_or("null".to_string()))),
Ok::<Bytes, String>(Bytes::from(r#", "vod": "#.to_string())),
Ok::<Bytes, String>(Bytes::from(vod_categories.unwrap_or("null".to_string()))),
Ok::<Bytes, String>(Bytes::from(r#", "series": "#.to_string())),
Ok::<Bytes, String>(Bytes::from(series_categories.unwrap_or("null".to_string()))),
Ok::<Bytes, String>(Bytes::from(r#"},"channels": {"live": ["#.to_string())),
Ok::<Bytes, String>(Bytes::from(r#"{"categories": {"live": "#)),
Ok::<Bytes, String>(Bytes::from(live_categories.unwrap_or("[]".to_string()))),
Ok::<Bytes, String>(Bytes::from(r#", "vod": "#)),
Ok::<Bytes, String>(Bytes::from(vod_categories.unwrap_or("[]".to_string()))),
Ok::<Bytes, String>(Bytes::from(r#", "series": "#)),
Ok::<Bytes, String>(Bytes::from(series_categories.unwrap_or("[]".to_string()))),
Ok::<Bytes, String>(Bytes::from(r#"},"channels": {"live": ["#)),
]).chain(live_stream).chain(stream::iter(vec![
Ok::<Bytes, String>(Bytes::from(r#"], "vod": ["#.to_string())),
Ok::<Bytes, String>(Bytes::from(r#"], "vod": ["#)),
])).chain(vod_stream).chain(stream::iter(vec![
Ok::<Bytes, String>(Bytes::from(r#"], "series": ["#.to_string())),
Ok::<Bytes, String>(Bytes::from(r#"], "series": ["#)),
])).chain(series_stream).chain(stream::iter(vec![
Ok::<Bytes, String>(Bytes::from(r"]}}".to_string())),
Ok::<Bytes, String>(Bytes::from(r"]}}")),
]));
return HttpResponse::Ok().content_type(mime::APPLICATION_JSON).body(BodyStream::new(json_stream));
} else if target.has_output(&TargetType::M3u) {
+3 -3
View File
@@ -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 {
+19
View File
@@ -22,4 +22,23 @@ pub struct PlaylistCategoriesDto {
pub vod: Option<Vec<PlaylistCategoryDto>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub series: Option<Vec<PlaylistCategoryDto>>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
pub struct TargetBouquetDto {
#[serde(skip_serializing_if = "Option::is_none")]
pub live: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vod: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub series: Option<Vec<String>>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
pub struct PlaylistBouquetDto {
#[serde(skip_serializing_if = "Option::is_none")]
pub xtream: Option<TargetBouquetDto>,
#[serde(skip_serializing_if = "Option::is_none")]
pub m3u: Option<TargetBouquetDto>,
}
+9
View File
@@ -667,4 +667,13 @@ pub fn rewrite_doc_urls(resource_url: Option<&String>, document: &mut Map<String
}
}
}
}
#[derive(Deserialize, Serialize)]
pub struct PlaylistXtreamCategory {
#[serde(alias = "category_id")]
pub id: String,
#[serde(alias = "category_name")]
pub name: String,
}
+3 -4
View File
@@ -125,14 +125,13 @@ impl Iterator for M3uPlaylistIterator {
}
}
pub struct M3uPlaylistIteratorText {
pub struct M3uPlaylistM3uTextIterator {
inner: M3uPlaylistIterator,
started: bool,
}
impl M3uPlaylistIteratorText {
impl M3uPlaylistM3uTextIterator {
pub fn new(
cfg: &Config,
target: &ConfigTarget,
@@ -145,7 +144,7 @@ impl M3uPlaylistIteratorText {
}
}
impl Iterator for M3uPlaylistIteratorText {
impl Iterator for M3uPlaylistM3uTextIterator {
type Item = String;
fn next(&mut self) -> Option<Self::Item> {
+31 -17
View File
@@ -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, M3uFilterError> {
M3uPlaylistIteratorText::new(cfg, target, user)
) -> Result<M3uPlaylistM3uTextIterator, M3uFilterError> {
M3uPlaylistM3uTextIterator::new(cfg, target, user)
}
pub async fn m3u_get_item_for_stream_id(stream_id: u32, cfg: &Config, target: &ConfigTarget) -> Result<M3uPlaylistItem, Error> {
pub async fn m3u_get_item_for_stream_id(stream_id: u32, cfg: &Config, target: &ConfigTarget) -> Result<M3uPlaylistItem, Error> {
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::<u32, M3uPlaylistItem>(&m3u_path, &idx_path, &stream_id)
}
}
}
pub fn iter_raw_m3u_playlist(config: &Arc<Config>, target: &ConfigTarget) -> Option<(FileReadGuard, impl Iterator<Item=(M3uPlaylistItem, bool)>)> {
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::<u32, M3uPlaylistItem>::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
}
}
+71 -38
View File
@@ -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<PathBuf> {
}
}
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<String> {
tokio::fs::read_to_string(file).await.ok()
}
pub async fn load_user_bouquet_as_json(cfg: &Config, username: &str) -> Option<String> {
pub async fn load_user_bouquet_as_json(cfg: &Config, username: &str, target: TargetType) -> Option<String> {
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<Vec<PlaylistCategoryDto>> {
async fn user_get_cluster_bouquet(cfg: &Config, username: &str, target: &TargetType, cluster: XtreamCluster) -> Option<Vec<String>> {
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::<Vec<PlaylistCategoryDto>>(&content) {
if !bouquet.is_empty() {
return Some(bouquet);
if let Some(value) = content {
if let Ok(bouquet) = serde_json::from_str::<Vec<String>>(&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<Vec<PlaylistCategoryDto>> {
user_get_cluster_bouquet(cfg, username, XtreamCluster::Live).await
pub(crate) async fn user_get_live_bouquet(cfg: &Config, username: &str, target: &TargetType) -> Option<Vec<String>> {
user_get_cluster_bouquet(cfg, username, target, XtreamCluster::Live).await
}
pub(crate) async fn user_get_vod_bouquet(cfg: &Config, username: &str) -> Option<Vec<PlaylistCategoryDto>> {
user_get_cluster_bouquet(cfg, username, XtreamCluster::Video).await
pub(crate) async fn user_get_vod_bouquet(cfg: &Config, username: &str, target: &TargetType) -> Option<Vec<String>> {
user_get_cluster_bouquet(cfg, username, target, XtreamCluster::Video).await
}
pub(crate) async fn user_get_series_bouquet(cfg: &Config, username: &str) -> Option<Vec<PlaylistCategoryDto>> {
user_get_cluster_bouquet(cfg, username, XtreamCluster::Series).await
pub(crate) async fn user_get_series_bouquet(cfg: &Config, username: &str, target: &TargetType) -> Option<Vec<String>> {
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<HashSet<String>> {
// 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<HashSet<String>> {
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<String, String> = 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);
}
}
}
}
+7 -7
View File
@@ -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::Item> {
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))
+19 -5
View File
@@ -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, M3uFilterError> {
XtreamPlaylistIteratorText::new(cluster, config, target, category_id, user).await
) -> Result<XtreamPlaylistJsonIterator, M3uFilterError> {
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()
}
}
}
}
pub(crate) async fn xtream_get_playlist_categories(config: &Config, target_name: &str, cluster: XtreamCluster) -> Option<Vec<PlaylistXtreamCategory>> {
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::<Vec<PlaylistXtreamCategory>>(&content).ok();
}
}
None
}