mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-11 10:22:10 +02:00
user boquet for m3u playlist
This commit is contained in:
@@ -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,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';
|
||||
|
||||
@@ -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>
|
||||
</>;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,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) ));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user