mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 17:16:26 +00:00
fix(channels): update "Copy URL" functionality to exclude web player parameters
This change modifies the "Copy URL" action in the Channels table to generate a plain proxy URL without including web player output profile parameters. The update ensures that copied links are suitable for external players, enhancing usability. Additionally, tests have been updated to verify the new behavior of the "Copy URL" feature.
This commit is contained in:
parent
84d2aedae5
commit
692f2d27c1
3 changed files with 75 additions and 36 deletions
|
|
@ -45,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Fixed
|
||||
|
||||
- **Channels table "Copy URL" no longer includes the web player's output profile.** The kebab-menu action was reusing the in-app preview URL builder, so copied links could include `output_format=mpegts` and `output_profile=...` from web-player prefs. Copy now emits a plain `/proxy/ts/stream/{uuid}` URL suitable for external players; Watch still applies player prefs.
|
||||
- **Redis-backed VOD sessions no longer leave stale profile connection reservations after multi-worker range-request teardown.** Jellyfin/FFmpeg clients that issue concurrent byte-range requests for one VOD session could finish teardown while another worker held the session metadata lock (`vod_connection_lock:*`). `decrement_active_streams_and_check()` then failed with `DECR-AS-CHECK failed: could not acquire lock`, skipped the profile-slot release, and left `profile_connections:*` and the session hash occupied until restart or manual Redis cleanup. Per-session `active_streams` is now mutated with Redis Lua (independent of the metadata lock), metadata saves omit and never clobber that counter, idle cleanup deletes the session hash only when still idle, and late metadata writes cannot recreate a deleted session. (Fixes #1426)
|
||||
- **EPG programme times no longer shift when PostgreSQL's server default timezone is non-UTC.** After the psycopg3 / Django upgrade, geventpool sessions were no longer pinned to UTC: Django's connection timezone setup is skipped whenever `self.pool` is truthy, and the older `connection_created` `SET TIME ZONE 'UTC0'` receiver was a nested closure registered with a weak reference, so it did not reliably stay registered (in production with `DEBUG=False` it was garbage-collected and never ran; `DEBUG=True` could keep it alive via Django's inspect LRU cache). Sessions that lacked a live pin therefore inherited the server default; on deployments whose default is non-UTC (for example from `/etc/localtime` bind-mounts), psycopg3 decoded `timestamptz` values under that zone and XMLTV output labeled local wall-clock times as `+0000`. The pool backend now sets `TimeZone=UTC` in the libpq startup packet so every pooled connection starts in UTC (and `RESET TimeZone` returns to UTC), and the fragile signal is removed. Stored data was never corrupted, only reads were wrong. (Fixes #651) — Thanks [@nagelm](https://github.com/nagelm)
|
||||
- **Frontend date/time unit tests no longer fail outside UTC / en-US.** Three tests encoded the machine timezone or locale into their expectations (`dateTimeUtils.isSame`, `RecordingUtils.toDateString`, `SeriesModalUtils.getEpisodeAirdate`), so the suite failed on boxes east of UTC or non-US locales. Fixtures now use local calendar datetimes and locale-computed expectations so the suite passes in every environment. — Thanks [@nagelm](https://github.com/nagelm)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,21 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
|
||||
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy, } from '@dnd-kit/sortable';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import ChannelForm from '../forms/Channel';
|
||||
import ChannelBatchForm from '../forms/ChannelBatch';
|
||||
|
|
@ -25,7 +40,10 @@ import {
|
|||
SquarePen,
|
||||
Tv2,
|
||||
} from 'lucide-react';
|
||||
import { listOverriddenFields, requeryChannels, } from '../../utils/forms/ChannelUtils.js';
|
||||
import {
|
||||
listOverriddenFields,
|
||||
requeryChannels,
|
||||
} from '../../utils/forms/ChannelUtils.js';
|
||||
import { buildLiveStreamUrl } from '../../utils/components/FloatingVideoUtils.js';
|
||||
import {
|
||||
ActionIcon,
|
||||
|
|
@ -78,7 +96,6 @@ import useWarningsStore from '../../store/warnings';
|
|||
import ConfirmationDialog from '../ConfirmationDialog';
|
||||
import useAuthStore from '../../store/auth';
|
||||
import { USER_LEVELS } from '../../constants';
|
||||
import { getShowVideoUrl } from '../../utils/cards/RecordingCardUtils.js';
|
||||
import {
|
||||
buildEPGUrl,
|
||||
buildFetchParams,
|
||||
|
|
@ -110,11 +127,7 @@ const ChannelEnabledSwitch = React.memo(
|
|||
|
||||
const handleToggle = () => {
|
||||
if (selectedTableIds.length > 1) {
|
||||
updateProfileChannels(
|
||||
selectedTableIds,
|
||||
selectedProfileId,
|
||||
!isEnabled
|
||||
);
|
||||
updateProfileChannels(selectedTableIds, selectedProfileId, !isEnabled);
|
||||
} else {
|
||||
updateProfileChannel(rowId, selectedProfileId, !isEnabled);
|
||||
}
|
||||
|
|
@ -333,8 +346,7 @@ const ChannelsTable = ({ onReady }) => {
|
|||
const [showOnlyStaleChannels, setShowOnlyStaleChannels] = useState(false);
|
||||
const [showOnlyOverriddenChannels, setShowOnlyOverriddenChannels] =
|
||||
useState(false);
|
||||
const [showOnlyCatchupChannels, setShowOnlyCatchupChannels] =
|
||||
useState(false);
|
||||
const [showOnlyCatchupChannels, setShowOnlyCatchupChannels] = useState(false);
|
||||
const [visibilityFilter, setVisibilityFilter] = useState('active');
|
||||
|
||||
const [paginationString, setPaginationString] = useState('');
|
||||
|
|
@ -424,25 +436,39 @@ const ChannelsTable = ({ onReady }) => {
|
|||
/**
|
||||
* Functions
|
||||
*/
|
||||
const handleFetchSuccess = useCallback((ids) => {
|
||||
setTablePrefs((prev) => ({ ...prev, pageSize: pagination.pageSize }));
|
||||
setAllRowIds(ids);
|
||||
hasFetchedData.current = true;
|
||||
if (!hasSignaledReady.current && onReady && tvgsLoaded) {
|
||||
hasSignaledReady.current = true;
|
||||
onReady();
|
||||
}
|
||||
}, [pagination.pageSize, setTablePrefs, setAllRowIds, onReady, tvgsLoaded]);
|
||||
const handleFetchSuccess = useCallback(
|
||||
(ids) => {
|
||||
setTablePrefs((prev) => ({ ...prev, pageSize: pagination.pageSize }));
|
||||
setAllRowIds(ids);
|
||||
hasFetchedData.current = true;
|
||||
if (!hasSignaledReady.current && onReady && tvgsLoaded) {
|
||||
hasSignaledReady.current = true;
|
||||
onReady();
|
||||
}
|
||||
},
|
||||
[pagination.pageSize, setTablePrefs, setAllRowIds, onReady, tvgsLoaded]
|
||||
);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const params = buildFetchParams({
|
||||
pagination, sorting, debouncedFilters, selectedProfileId,
|
||||
showDisabled, showOnlyStreamlessChannels, showOnlyStaleChannels,
|
||||
showOnlyOverriddenChannels, visibilityFilter, showOnlyCatchupChannels,
|
||||
pagination,
|
||||
sorting,
|
||||
debouncedFilters,
|
||||
selectedProfileId,
|
||||
showDisabled,
|
||||
showOnlyStreamlessChannels,
|
||||
showOnlyStaleChannels,
|
||||
showOnlyOverriddenChannels,
|
||||
visibilityFilter,
|
||||
showOnlyCatchupChannels,
|
||||
});
|
||||
const paramsString = params.toString();
|
||||
|
||||
if (fetchInProgressRef.current && lastFetchParamsRef.current === paramsString) return;
|
||||
if (
|
||||
fetchInProgressRef.current &&
|
||||
lastFetchParamsRef.current === paramsString
|
||||
)
|
||||
return;
|
||||
|
||||
const currentFetchVersion = ++fetchVersionRef.current;
|
||||
lastFetchParamsRef.current = paramsString;
|
||||
|
|
@ -450,7 +476,10 @@ const ChannelsTable = ({ onReady }) => {
|
|||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const [, ids] = await Promise.all([queryChannels(params), getAllChannelIds(params)]);
|
||||
const [, ids] = await Promise.all([
|
||||
queryChannels(params),
|
||||
getAllChannelIds(params),
|
||||
]);
|
||||
fetchInProgressRef.current = false;
|
||||
if (currentFetchVersion !== fetchVersionRef.current) return;
|
||||
setIsLoading(false);
|
||||
|
|
@ -462,9 +491,16 @@ const ChannelsTable = ({ onReady }) => {
|
|||
throw error;
|
||||
}
|
||||
}, [
|
||||
pagination, sorting, debouncedFilters, selectedProfileId,
|
||||
showDisabled, showOnlyStreamlessChannels, showOnlyStaleChannels,
|
||||
showOnlyOverriddenChannels, visibilityFilter, showOnlyCatchupChannels,
|
||||
pagination,
|
||||
sorting,
|
||||
debouncedFilters,
|
||||
selectedProfileId,
|
||||
showDisabled,
|
||||
showOnlyStreamlessChannels,
|
||||
showOnlyStaleChannels,
|
||||
showOnlyOverriddenChannels,
|
||||
visibilityFilter,
|
||||
showOnlyCatchupChannels,
|
||||
handleFetchSuccess,
|
||||
]);
|
||||
|
||||
|
|
@ -609,9 +645,9 @@ const ChannelsTable = ({ onReady }) => {
|
|||
return '';
|
||||
}
|
||||
|
||||
const path = getShowVideoUrl(channel, env_mode);
|
||||
const path = `/proxy/ts/stream/${channel.uuid}`;
|
||||
if (env_mode == 'dev') {
|
||||
return path;
|
||||
return `${window.location.protocol}//${window.location.hostname}:5656${path}`;
|
||||
}
|
||||
return `${window.location.protocol}//${window.location.host}${path}`;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -57,9 +57,6 @@ vi.mock('../../../utils/forms/ChannelUtils.js', () => ({
|
|||
vi.mock('../../../utils/components/FloatingVideoUtils.js', () => ({
|
||||
buildLiveStreamUrl: vi.fn((path) => path),
|
||||
}));
|
||||
vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
|
||||
getShowVideoUrl: vi.fn(() => '/proxy/ts/stream/uuid-1'),
|
||||
}));
|
||||
vi.mock('../../../utils/tables/ChannelsTableUtils.js', () => ({
|
||||
buildEPGUrl: vi.fn(() => 'http://localhost/output/epg'),
|
||||
buildFetchParams: vi.fn(() => new URLSearchParams()),
|
||||
|
|
@ -1040,7 +1037,7 @@ describe('ChannelsTable', () => {
|
|||
// ── Copy URL ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('"Copy URL" menu item', () => {
|
||||
it('calls copyToClipboard when "Copy URL" is clicked', () => {
|
||||
it('copies a plain channel proxy URL without web player params', () => {
|
||||
const channel = makeChannel({ uuid: 'uuid-1' });
|
||||
const { tableInstance } = setupMocks();
|
||||
render(<ChannelsTable />);
|
||||
|
|
@ -1053,7 +1050,12 @@ describe('ChannelsTable', () => {
|
|||
el.textContent.includes('Copy URL')
|
||||
);
|
||||
fireEvent.click(copyBtn);
|
||||
expect(copyToClipboard).toHaveBeenCalled();
|
||||
expect(copyToClipboard).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/proxy\/ts\/stream\/uuid-1$/)
|
||||
);
|
||||
const copiedUrl = copyToClipboard.mock.calls[0][0];
|
||||
expect(copiedUrl).not.toContain('output_profile');
|
||||
expect(copiedUrl).not.toContain('output_format');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue