Updated kodi settings on Lenovo

This commit is contained in:
2026-03-22 22:28:43 +01:00
parent 725dfa7157
commit 32b5a81da6
10925 changed files with 575678 additions and 5511 deletions

View File

@@ -0,0 +1,39 @@
![PseudoTV Live](https://raw.githubusercontent.com/PseudoTV/PseudoTV_Live/master/plugin.video.pseudotv.live/resources/images/fanart.jpg)
[![PseudoTV Live](https://opengraph.githubassets.com/b515e27858c045536f54116a571f79bda90cde077f4a9e87af8908cb0801b6a2/PseudoTV/PseudoTV_Live)](https://opengraph.githubassets.com/b515e27858c045536f54116a571f79bda90cde077f4a9e87af8908cb0801b6a2/PseudoTV/PseudoTV_Live)
# PseudoTV Live for Kodi™
## What is it?:
PseudoTV Live transforms your Kodi Library and Sources (Plugins, UPnP, etc...) into a broadcast or cable TV emulator, complete with configurable channels. UI Provided by Kodis PVR frontend via IPTV Simple.
[Changelog](https://github.com/PseudoTV/PseudoTV_Live/raw/master/plugin.video.pseudotv.live/changelog.txt)
[Wiki: Github](https://github.com/PseudoTV/PseudoTV_Live/wiki)
[Forum: Kodi](https://forum.kodi.tv/showthread.php?tid=355549)
[Discussion: Kodi](https://forum.kodi.tv/showthread.php?tid=346803)
[Discussion: Github](https://github.com/PseudoTV/PseudoTV_Live/discussions)
Repository Installation:
[![License](https://img.shields.io/github/license/PseudoTV/PseudoTV_Live?style=flat-square)](https://github.com/PseudoTV/PseudoTV_Live/blob/master/LICENSE)
[![Codacy Badge](https://img.shields.io/codacy/grade/efcc007bd689449f8cf89569ac6a311b.svg?style=flat-square)](https://www.codacy.com/app/PseudoTV/PseudoTV_Live/dashboard)
[![GitHub commit activity](https://img.shields.io/github/commit-activity/m/PseudoTV/PseudoTV_Live.svg?color=red&style=flat-square)](https://github.com/PseudoTV/PseudoTV_Live/commits?author=Lunatixz)
[![Kodi URL](https://img.shields.io/badge/Supports-Kodi%2019+-blue.svg?style=flat-square)](https://kodi.tv/download)
[![Kodi Donate](https://img.shields.io/badge/Donate%20to-Kodi-blue.svg?style=flat-square)](https://kodi.tv/contribute/donate)
[![Lunatixz Patreon](https://img.shields.io/badge/Patreon-Lunatixz-blue.svg?style=flat-square)](https://www.patreon.com/pseudotv)
[![Lunatixz Paypal](https://img.shields.io/badge/Paypal-Lunatixz-blue.svg?style=flat-square)](https://paypal.me/Lunatixz)
# Special Thanks:
- @xbmc If you are enjoying this project please donate to Kodi!
- @phunkyfish for his continued work and help with IPTV Simple.
- @IAmJayFord for awesome PseudoTV Live Icon/Fanart sets.
- @preroller for fantastic PseudoTV Live Bumpers.
### License
* [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
* Copyright 2009-2025

View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon id="plugin.video.pseudotv.live" version="0.6.1q" name="PseudoTV Live" provider-name="Lunatixz">
<requires>
<import addon="xbmc.python" version="3.0.1"/>
<import addon="script.module.six" version="1.0.0"/>
<import addon="script.module.kodi-six" version="0.1.3.1"/>
<import addon="script.module.infotagger" version="0.0.5"/>
<import addon="script.module.simplecache" version="1.0.0"/>
<import addon="script.module.requests" version="0.0.1"/>
<import addon="script.module.pyqrcode" version="1.2.1+matrix.1"/>
<import addon="plugin.library.node.editor" version="0.0.1"/>
<import addon="resource.images.studios.white" version="0.0.1"/>
<import addon="resource.images.musicgenreicons.text" version="0.0.1"/>
<import addon="resource.images.moviegenreicons.transparent" version="0.0.1"/>
<import addon="resource.images.pseudotv.logos" optional="true" version="0.0.1"/>
<import addon="resource.images.overlays.crttv" optional="true" version="0.0.2"/>
<import addon="resource.videos.bumpers.pseudotv" optional="true" version="0.0.1"/>
<import addon="resource.videos.bumpers.kodi" optional="true" version="0.0.1"/>
<import addon="resource.videos.ratings.mpaa.classic" optional="true" version="0.0.4"/>
<import addon="script.module.youtube.dl" optional="true" version="23.04.01+matrix.1"/>
<import addon="script.module.pil" optional="true" version="5.1.0"/>
</requires>
<extension point="xbmc.python.pluginsource" library="resources/lib/default.py">
<provides>video</provides>
</extension>
<extension point="xbmc.service" library="resources/lib/services.py"/>
<extension point="kodi.context.item">
<menu id="kodi.core.main">
<menu>
<label>PseudoTV Live</label>
<item library="resources/lib/context_record.py" args="add">
<label>30115</label>
<visible>[String.Contains(ListItem.Plot,"item=") + ListItem.HasEpg] + [Window.IsVisible(tvguide)|Window.IsVisible(tvsearch)|Window.IsVisible(tvchannels)]</visible>
</item>
<item library="resources/lib/context_record.py" args="del">
<label>30117</label>
<visible>[String.StartsWith(ListItem.PVRInstanceName, PseudoTV Live) + Window.IsVisible(tvrecordings)]</visible>
</item>
<item library="resources/lib/context_play.py" args="playlist">
<label>30076</label>
<visible>[String.Contains(ListItem.Plot,"item=") + ListItem.HasEpg] + [Window.IsVisible(tvguide)|Window.IsVisible(tvsearch)|Window.IsVisible(tvchannels)]</visible>
</item>
<item library="resources/lib/context_info.py" args="info">
<label>30075</label>
<visible>[String.Contains(ListItem.Plot,"item=") + ListItem.HasEpg] + [Window.IsVisible(tvguide)|Window.IsVisible(tvsearch)|Window.IsVisible(tvchannels)]</visible>
</item>
<item library="resources/lib/context_info.py" args="match">
<label>30091</label>
<visible>[String.Contains(ListItem.Plot,"item=") + ListItem.HasEpg] + [Window.IsVisible(tvguide)|Window.IsVisible(tvsearch)|Window.IsVisible(tvchannels)]</visible>
</item>
<item library="resources/lib/context_info.py" args="browse">
<label>30087</label>
<visible>[String.Contains(ListItem.Plot,"item=") + ListItem.HasEpg] + [Window.IsVisible(tvguide)|Window.IsVisible(tvsearch)|Window.IsVisible(tvchannels)]</visible>
</item>
<item library="resources/lib/context_create.py">
<label>30114</label>
<visible>[!ListItem.IsPlayable + ListItem.IsFolder] + [!String.IsEmpty(ListItem.Label)]</visible>
</item>
<item library="resources/lib/context_create.py" args="manage">
<label>30107</label>
<visible>[String.Contains(ListItem.Plot,"item=")] + [Window.IsVisible(tvchannels)]</visible>
</item>
</menu>
</menu>
</extension>
<extension point="xbmc.addon.metadata">
<summary lang="en_GB">PseudoTV Live acts like a set-top box for Kodi!</summary>
<description lang="en_GB">PseudoTV Live acts similar to normal broadcast or cable TV, complete with pre-defined and user-defined channels. UI Provided by Kodi.</description>
<platform>all</platform>
<license>GPL-3.0-or-later</license>
<source>https://github.com/PseudoTV/PseudoTV_Live</source>
<forum>https://forum.kodi.tv/showthread.php?tid=355549</forum>
<disclaimer lang="en_GB">BETA PROJECT; SUBJECT TO BUGS</disclaimer>
<reuselanguageinvoker>true</reuselanguageinvoker>
<assets>
<icon>resources/images/icon.png</icon>
<fanart>resources/images/fanart.jpg</fanart>
<screenshot>resources/images/screenshot01.png</screenshot>
<screenshot>resources/images/screenshot02.png</screenshot>
<screenshot>resources/images/screenshot03.png</screenshot>
<screenshot>resources/images/screenshot04.png</screenshot>
</assets>
</extension>
</addon>

View File

@@ -0,0 +1,372 @@
v.0.6.2
- Optimized background logo tasks to prevent potential deadlocks.
- Resolved repeated "Offline" server notifications.
- Corrected an issue where channels failed to switch or load correctly.
- Added CPU/IO Benchmark to Utility Menu.
- Fixed Reoccurring welcome prompt.
- Fixed Failed library parsing inadvertently clearing autotuned channels.
- Improved background logo parsing.
- Fixed Channel Manager not saving paths.
- Fixed Utility Menu options not behaving properly.
- Fixed Issue where missing resource packs crashed channel manger.
- Tweaked Channel building to assure equal content parsing between paths.
- Added New Advanced Channel Rules:
- Page Limit
- Added Channel bug transparency setting and Advanced Overlay Rule.
- Tweaked Advanced Overlay Rules and removed redundant settings to limit confusion. Stack multiple rules to restore previous functionality.
- This may required users to delete their existing rules; save, then re-add them.
- Affected Rules:
- All Overlay Rules
- Duration Options
- Include Options
- Interleaving
- Limits & Sort Methods
- Even Show Distribution
- Force Episode Ordering
v.0.6.1
- Improved logo detection.
- Possible Fix for channel content not changing.
- Reworked Autotuning, Backups and Recovery; Feedback appreciated...
- Updated Seasonal channel to include days of the week; expanding holiday channel content.
- Fixed Channel Manager "Select","Auto" channel logo.
- Refactored "Paused Channel" Adv. channel rule to work between multi-room instances [Experimental].
- Added Channel editing support for multi-room instances [Experimental].
- Fixed Adv. Channel rules "Post-Roll.
- Changed how "TVShow" type Smart-playlist are handled in-order to flatten hierarchy for cleaner parsing.
- Resolved issue with the "Post-Roll" advanced channel rule.
- Corrected advanced rule vignette image selection from resources.
- Fixed vignette overlay size not applying to resolutions above 1080p.
- Addressed a bug affecting progressive channel updates.
- Included various miscellaneous improvements.
- Enhanced logo parsing:
- Improved startup performance by queuing missing logos for future caching.
- Once the cache is complete, logos are gradually integrated into circulation automatically.
- Known issues:
- If you encounter incorrect channel logos, over time they will correct themselves or
users can delete their Kodi\portable_data\addons\script.module.simplecache\simplecache.db file while Kodi is not running. Forcing channel logos to repopulate.
- Notice: Let us aim to make PseudoTV Live 100% bug-free by its 5th anniversary (June 29, 2025).
Please test thoroughly and help report issues: https://github.com/PseudoTV/PseudoTV_Live/wiki/Support
v.0.6.0
- Refactored Overlays.
- Fixed "Run Autotune" in utilities menu.
- Miscellaneous tweaks & improvements.
v.0.5.9
- Miscellaneous tweaks & improvements.
- Added JSONRPC query timeout to Misc. settings (only viewable when debugging is enabled).
- Removed iSpot Adverts and IMDB Trailers settings. Users can manual add these sources as needed via. Adv. Channel rules.
- Introduced new parsing procedures for reading `pvr://` directories.
- This enhancement aims to resolve playback issues with PVR recordings and newly added saved searches.
- Please if you have not done so already add 'pvr://' as a video source in Kodi to whitelist this directory.
- Added support for saved PVR searches (currently supports PseudoTV Channels only); available under "Mixed" Autotuning.
v.0.5.8
- Enhanced Channel Manager UI and refactored code.
- Resolved issues with some advanced channel rules not applying correctly.
- Deprecated global "Sort Method"; sort methods are now applied per channel via advanced channel rules.
- Modified the application of sort methods to dynamic smart playlists; advanced channel rules now supersede dynamic rules.
- Added "Preview" button in channel settings for Channel Manager [Experimental].
- Preview channels in order as they would appear in the EPG.
- Paths and rules apply as they would during channel building.
- Useful for testing paths and rules, with build time information included.
- Some utility items are now hidden and only visible with "Enable Debugging" turned on.
- Excluded some path options based on "radio" (i.e., Music Channel) in the Channel Manager.
- Fixed rollback playcount issue.
- Fixed overlay tool issue saving "On Next" position.
- Smartplaylist limits now override global pagination size; "No Limit" defaults back to pagination size.
- Added channel guidedata reset to the Channel Manager. Channel changes will now trigger a complete rebuild of guidedata to reflect new changes.
- Added new "On Next" controls to the overlay position utility.
- Various tweaks to playback, error handling, channel bug, and on-next logic.
- Improved stability and reduced memory burden from background service.
- Fixed "Restart Percentage" setting to allow 0% (i.e., disable restart prompt).
v.0.5.7
- Enhanced stability by replacing Kodi's segfault-prone busy dialog with a custom busy dialog.
- Improved "ON Next" options to include artwork or text-based prompts.
- Enhanced background overlay.
- Fixed channel manager path duplication issue.
- Improved file migration when changing centralized file locations.
- Changed default value for even show distributions to 0.
- Moved custom user groups out of settings into the group select list in the Channel Manager.
- Fixed PseudoTV not respecting user subtitle preference.
- Fixed channel bug and On-Next position not changing based on user settings.
- Improved background interface and content transitioning.
- Fixed deadlock issue when checking multi-room status on startup.
- Enhanced URL caching.
- Added experimental new advanced channel rule "Pause Channel." The rule pauses channel content when not viewing.
- Assistance debugging this feature is appreciated.
- Known issues: Resume playback from the last watched time may not always work.
- Focus on whether the content stays in position and the EPG guide presents correct metadata.
- Improved overlay position utility to include On-Next position and future vignette position.
- Added "Global" option for On-Next position, including advanced channel rules.
- Added "Global" option for On-Next color change, including advanced channel rules.
v.0.5.6
- Improved vignette (W.I.P) available in advanced channel rules.
- Added global interleaving value and advanced interleaving channel rule.
- Enhanced even distribution, now includes "Force Episode Ordering".
- Improved channel bug position tool, now includes overlay vignette if applicable.
- Improved Channel Manager's Path browser, now includes resource paths.
- Refactored pre-roll/post-roll fillers.
- Added pre-roll/post-roll options to advanced channel rules.
- Enhanced advanced channel rules.
- Fixed overlay issues introduced in previous versions.
- Improved MediaInfo support for external `mediainfo.xml` file parsing.
- Fixed issue with disappearing channels from `m3u`.
- Tweaked background overlay.
- Added multi-room channels to the Channel Manager with default server option in settings.
- Work-in-progress: there will be bugs (remote save disabled until finished). For proper system setup, info can be found on the wiki under "Recommended Prerequisites."
- All Kodi instances are required to broadcast Zeroconf for two-way communication between PseudoTV Live instances.
- Default channel list:
- Auto: Local if available, then first online server from the enabled server list, else ask.
- Ask: Select from any Zeroconf online instance found.
- Added new global rule "Show OSD on change"; display OSD info when new channel content starts. Advanced channel rules available.
- Fixed user-reported issues with browsing channel logos, parsing stacked MP4 files, and moving channels within the Channel Manager.
- Added Zeroconf multi-room configuration, replacing the previous pairing method.
- To use multi-room, "Zeroconf" must be enabled under Kodi "Services/General" settings.
(Windows users must install Apple's Bonjour service: https://support.apple.com/en-us/106380)
- Announcement & discovery are fully automated; new instances are enabled by default.
- No pairing procedures required; ignore past methods.
- Receive notifications when new instances are detected.
- Previous pairings are unchanged; no user action required.
- All multi-room ports TCP/UDP are required to be identical on each instance of PseudoTV.
- It's recommended to leave ports unchanged. Ports settings are hidden and can be unlocked by enabling "Debugging" in settings.
- Added new welcome QR dialog to work in progress wiki (Currently unavailable).
- Added multi-room option to autotuning.
- When no channel backups are found and multi-room instances are available.
- Added global sort method to settings.
- Set the default method for all channels with exceptions below.
- Per channel sort method is overridden by smart playlists, dynamic playlists, and advanced channel rules containing an existing sort method.
- Moved "Remove Server" to "Select Server" list.
- Tweaked multiprocessing and cache.
- Improved filling guidedata for channels with limited or short-duration media.
- Various improvements, tweaks, and fixes.
- Improved multi-room connections and notifications.
- Added new channel path options to browse dialog utility.
- "Import STRM" Import paths, i.e., directories within a STRM file. Not meant for STRMs containing individual media (directories only!).
- "Basic Playlist" Create a channel from a single `.cue`, `.m3u`, `.m3u8`, `.strm`, `.pls`, `.wpl` file (content only!) (W.I.P).
v.0.5.5
- Added robust debugging with easy log submission and user UI (W.I.P).
- Minimum debug level setting: Filter less important entries to reduce file size.
- QR-Code PseudoTV Live Forums.
- QR-Code Snapshot UI (Unfiltered)
- QR-Code "Submit Snapshot" Upload to `paste.kodi.tv` (filtered, sensitive information stripped before upload).
- Improved settings; reordered and cleaned in preparation for Kodi repository submission.
- Debugging now disabled by default.
- Automatic IPTV-Simple PVR refresh/configuration disabled by default.
- Added QR-Code dialog.
- Updated September/October seasonal channels.
- Optimized cache initialization.
- Improved STRM duration detection and playback.
- Removed "Tweak" settings; moved to "Globals".
- Fixed "Play from here" playback.
- Added local to URL image converter; hosted via Kodi webserver and PseudoTV Server.
- Added "Smart" TV-guide loading as default when launching PseudoTV Live via Kodi UI.
- If multiple instances, the guide will open to "PseudoTV Live [All channels]"
- Otherwise, the guide will open to your local instance name "PseudoTV Live".
- Fallback to "[All channels]" if no match is found.
- Improved "Restart" button; restart button disabled by default.
- Improved PVR backend refresh.
v.0.5.4
- Added provider metadata to recordings.
- Refactored playback:
- Improved playlist/broadcast/VOD callbacks and handling.
- New EPG (Guide) behavior: Play media as VOD from any position regardless of playback type.
- "Play Programme" context will launch continued playback if enabled in Kodi's LiveTV settings.
- New: On VOD finish, the channel will resume in real-time.
- Added "Build Filler Subfolders" setting to "Fillers".
- Added "Bonjour on startup" setting to "Multi-Room".
- Added "Allow PVR Refresh" to "Misc." settings.
- Added optional saving of accurate duration metadata directly from Kodi's video player during playback.
- Added gzip compression support to `xmltv` server.
- Improved playlist playback mode.
- Media can start by any EPG cell, i.e., it's not bound to linear time.
- Improved PVR provider metadata.
- Moved all context menus under "PseudoTV Live".
- Added channel manager to channel list context menu.
- Fixed autotune prompt not showing.
- Fixed disable Trakt, playcount rollback not triggering.
- Added "Restart" replay prompt.
- When media is in progress, a button will appear to restart the program from the beginning.
Media will be launched as a singular VOD event.
- Global and advanced channel rule to disable/set restart parameters.
- Added "Resume Later" recordings option.
- When using "Add to Recordings" on currently playing content, an option to "Incl. Resume" will be offered.
Resume later will start future playback at the resume position for easy viewing later.
- Added temporary debug logging to fix failed channel building during fillers.
- Further logs will be needed to help resolve the issue; please submit logs. Thank you.
- Tweaked background queue ordering.
- Improved startup pairing.
- Fixed background service not idling.
- Added "Seek Tolerance" as a runtime offset to keep durations via meta-providers under actual runtimes.
- Tweaked startup order; moved HTTP server to higher priority.
- Fixed http hosted genres.
- Improved multi-room advanced rule support via paired clients.
- Advanced rules for playback, overlays, etc., are now shared between instances.
v.0.5.3
- Fixed channel manager "Add Path".
- Tweaked PVR backend reload.
- Improved user folder file transfer.
- Improved directory walking for plugins and resources.
- Added refactored "hack" method for writing PVR instance settings.
- Added rename default device name with prompt:
- New multi-room requires a unique device name.
- Removed all client/server references and settings.
- Added "Run Autotune" option to utility menu.
- Added new multi-room pairing:
- New method pairs instances together, no limit to paired devices.
Each instance has its own channels to manage; the pairing process only shares `m3u/xmltv/genre` over http.
For multi-room to work, Kodi needs to be configured with a centralized database, and all media must use shared paths.
Start the pairing process by clicking "Bonjour Announcement" under multi-room settings.
You'll have 15 minutes to start Kodi on another device, which for the first 60 seconds at startup will look for pairing.
Once paired, you can enable the new server in multi-room settings "Select Servers".
PseudoTV Live will configure IPTV Simple PVR backend to use the selected server's files.
Feature is a work-in-progress; please provide feedback and debug logs when necessary. Thank you!
v.0.5.2
- Added removal of invalid characters from channel names.
- Moved logo folder to user folder (e.g., `\cache\logos`).
- Added local fillers folder (e.g., `\cache\fillers`).
- Added automatic creation of filler folders based on your current channels and genres to the utilities menu.
- Changed default settings for accurate duration to "Prefer Kodi Metadata" & include fillers to "False".
- Added force episode sort channel rule.
- Improved queue priorities.
- Added PVR backend refresh at the end of channel building:
- Forces Kodi to recognize recent channel/EPG changes.
- May cause some channel EPGs to display a blank cell until the PVR backend refreshes.
- Working on a long-term solution; feedback is appreciated.
- Fixed a few user-reported issues introduced in recent iterations:
- Channels not building due to limit smart playlist parsing error.
- Channels ignoring auto pagination, leading to repeated content.
- Completed holiday channels.
- Fixed playlist playback not progressing.
- Fixed recordings persistence issues.
- Added automatic recordings cleanup to settings.
- Improved channel manager logo utility.
- Improved channel manager path utility.
- Improved channel content parsing.
- Added advanced channel rules UI to the Channel Manager.
- Added dynamic smart playlist builder to path selections:
- Allows users to build on-the-fly dynamic smart playlists without the need for a smart playlist or node.
- Added the first round of advanced channel rules, more to come.
- Added update available notification.
v.0.5.1
- Enhanced background services.
v.0.5.0
- Added seasonal channel cleanup.
- Added YouTube duration detection (requires YouTube_DL).
- Added third-party duration parsing via external Python libraries (Hachoir, MediaInfo, FFProbe, MoviePY, OpenCV).
- Improved MP4 duration parsing.
- Added iSpot Adverts support.
- Finalized global fillers.
- Fixed "On Next" global setting; limited notifications to content above 15 minutes and ignore fillers.
- Finalized playcount rollback.
- Simplified filler settings. Advanced channel rules will feature more controls on release.
- Refactored internal references and compressed data strings using zlib, reducing memory burden and Kodi EPG database size.
- Tweaked onChange logic to improve callback performance.
v.0.4.9
- Improved MP4 duration metadata detection.
- Fixed seasons returning incorrect holiday for the first week of April.
- Fixed seasonal logos disappearing.
- Enhanced playback error detection.
- Improved trailer parsing from Kodi & IMDB Trailers plugin.
- Improved "Auto" calculations for fillers.
- "Auto" attempts to fill the time between media to the nearest 15-minute mark.
- Fixed client detection issues introduced in the last build.
v.0.4.8
- The following steps are required:
- Open PseudoTV Live settings, under Miscellaneous; click "Utility Menu" and select "Delete M3U/XMLTV".
- Open Kodi settings, under PVR & Live TV; click "clear data" and select "All".
- Refactored announcements & discoveries for server/client multi-room (W.I.P).
- Refactored HTTP server.
- Added disable Trakt scrobbling during playback to global options.
- Added rollback watched playcount & resume points to global options.
- Fixed TV bumpers and resources (W.I.P); see readme for details.
- Added TV adverts and resources (W.I.P); see readme for details.
- Added trailers and resources (W.I.P); see readme for details.
v.0.4.7
- The following steps are required:
- Open PseudoTV Live settings, under Miscellaneous; click "Utility Menu" and select "Delete M3U/XMLTV".
- Open Kodi settings, under PVR & Live TV; click "clear data" and select "All".
- Fixed miscellaneous playback issues.
- Fixed movie rating filler and resources.
- Enable fillers under global settings, then verify MPAA resource installed under fillers.
- Ratings are only added before a movie and currently only support the U.S. rating system (MPAA).
- If any overseas users would like support, contact me at [Lunatixz on the Kodi forums].
- MPAA resources are available via the Lunatixz or PseudoTV repository.
- Updated April seasonal channels.
- Fixed channel bug not displaying the correct logo.
v.0.4.6
- Refactored seasonal & provisional autotuning.
- Added "Even Show Distribution" rule; enabled by default under global settings.
- TV Networks, TV Genres, Mixed Genres & Seasonal include even show distribution.
- Autotuned TV genre, mixed genres are now random and no longer in episode order.
- Temporarily disabled Bonjour announcement/discovery.
v.0.4.5
- The following steps are required:
- Open PseudoTV Live settings, under Miscellaneous; click "Utility Menu" and select "Delete M3U/XMLTV".
- Open Kodi settings, under PVR & Live TV; click "clear data" and select "All".
- Improved channel loading times.
- Enhanced background tasks and services.
- Improved playback using new IPTV Simple methods (thanks to `@phunkyfish`).
- Refactored all playback handlers, including radio.
- Fixed bug causing duplicate XMLTV entries to share the same start time, leading to empty EPG cells.
- Fixed a rare instance where existing channels were not detected/imported when rebuilding the library database from scratch.
- Added "Rebuild Library" to the utility menu. Forces a library rebuild to detect recent Kodi library additions. Default behavior is for the library to self-update every few days to hours.
- Added "Welcome" prompt to help new users understand and operate PseudoTV Live. Suggestions for improvements are welcome.
- Removed "UpNext" support.
v.0.4.4
- Added "Network Folder" clients' ability to edit autotuned channels on server (W.I.P).
- Moved `channels.json` and `library.json` files to "Centralized Folder" for network/client accessibility.
- Improved overlay functions to reduce memory overhead.
- Added "Show M3U/XMLTV" option to the utility menu (debugging tool).
v.0.4.3
- Added "Hack" method for automatically configuring IPTV-Simple (manual configuration no longer necessary).
- Improved usability/notifications.
- Fixed Channel Manager - channels removed/missing after editing.
- Added support for upcoming IPTV-Simple update allowing audio passthrough for PVR content.
- Added "Add to Recordings" context option; stores given media as a PVR recording; "Watch Later" feature (experimental - feedback appreciated).
- Known issue with recordings: meta information inconsistently displays. Working with `@phunkyfish` to resolve this bug.
- Added "Force High-Power" option under miscellaneous settings. Disables performance throttling on low-power devices.
- Improved file locking for upcoming web UI.
- Temporarily disabled multi-room discovery/announcements.
- Improved flow controls for background management (experimental - feedback appreciated).
- Fixed "Move"/"Delete" channel manager buttons.
v.0.4.2
- Improved "Find More" context option. Now supports Embuary Helpers find similar.
- Miscellaneous tweaks and bug fixes.
- Fixed forced autotuning overriding custom channels.
- Improved seasonal channels and channel recovery.
- Fixed seasonal channel.
- Enhanced in-app Channel Manager logo tool.
- Browse: Directory navigation.
- Select: Choose from matching results.
- Match: Automatically choose the first match.
- Improved in-app Channel Manager.
- Added "Add to PseudoTV Live" context menu option for effortless channel configuration (experimental).
- Added "Move" channel manager option for easy channel renumbering.
- Fixed radio playback failing due to URL encoding.
- Tweaked UpNext not creating a new instance.
- Fixed playback error introduced in the previous release.
- Fixed playlist/play from here playback issues.
v.0.4.1
- Enhanced playback error detection.
- Autotuned channels added to "Favorite" group.
- Miscellaneous tweaks.
- Fixed "library" content not respecting "specials" & "extras

View File

@@ -0,0 +1,33 @@
{
"iptv": {
"id": "",
"type": "iptv",
"name": "",
"description": "",
"icon": "",
"m3u": {
"path": "",
"slug": "",
"provider": ""
},
"xmltv": {
"path": ""
}
},
"vod": [{
"id": "",
"type": "vod",
"name": "",
"description": "",
"icon": "",
"path": ""
}],
"live": [{
"id": "",
"type": "live",
"name": "",
"description": "",
"icon": "",
"path": ""
}]
}

View File

@@ -0,0 +1,13 @@
{
"id": "",
"type": "",
"number": 0,
"name": "",
"logo": "",
"path": [],
"group": [],
"rules": {},
"catchup": "vod",
"radio": false,
"favorite": false
}

View File

@@ -0,0 +1,5 @@
{
"uuid": "",
"channels": [],
"imports": []
}

View File

@@ -0,0 +1,148 @@
<!--
The following are the DVB Genre Id's used for reference
Source: https://www.etsi.org/deliver/etsi_en/300400_300499/300468/01.11.01_60/en_300468v011101p.pdf
Page 40
Note: the first 4 bits is genre and last is sub genre
Mapping DVB Genres:
- The content below is a reference for Genre Text Mappings
There shoud be no reason to modify this file unless the DVB standard changes.
NOTE: IF YOU MODIFY THIS FILE IT WILL BE OVERWRITTEN NEXT TIME THE ADDON IS STARTED
If you have changes either create a PR containing the changes or an issue with details at:
https://github.com/kodi-pvr/pvr.iptvsimple
https://github.com/kodi-pvr/pvr.iptvsimple#using-a-mapping-file-for-genres
https://codedocs.xyz/xbmc/xbmc/group__cpp__kodi__addon__pvr___defs__epg___e_p_g___e_v_e_n_t.html#ga0ac9430768fc11505a193241fc2d4008
-->
<!-- - 0x10: General Movie / Drama -->
<!-- - 0x20: News / Current Affairs -->
<!-- - 0x30: Show / Game Show -->
<!-- - 0x40: Sports -->
<!-- - 0x50: Children's / Youth Programmes -->
<!-- - 0x60: Music / Ballet / Dance -->
<!-- - 0x70: Arts / Culture -->
<!-- - 0x80: Social / Political / Economics -->
<!-- - 0x90: Education / Science / Factual -->
<!-- - 0xA0: Leisure / Hobbies -->
<!-- - 0xB0: Special Characteristics -->
<genres>
<name>PseudoTV Live</name>
<!-- UNDEFINED (Grey) -->
<genre genreId="0x00">Undefined/Unavailable/Unrated/NA/NR</genre>
<!-- MOVIE/DRAMA (Blue) -->
<genre genreId="0x10">Movie/Drama/TV Movie</genre>
<genre genreId="0x11">Detective/Thriller/Crime/Suspense</genre>
<genre genreId="0x12">Action/Adventure/Western/War</genre>
<genre genreId="0x13">Science Fiction/Fantasy/Horror/Sci-Fi</genre>
<genre genreId="0x14">Comedy</genre>
<genre genreId="0x15">Soap/Melodrama/Folkloric</genre>
<genre genreId="0x16">Romance</genre>
<genre genreId="0x17">Serious/Classical/Religious/Historical/History/Classic</genre>
<genre genreId="0x18">Adult/XXX/Mature</genre>
<!-- NEWS/CURRENT AFFAIRS (Drk. Green) -->
<genre genreId="0x20">News/Current Affairs/Information</genre>
<genre genreId="0x21">Weather/Weather Report</genre>
<genre genreId="0x22">News Magazine</genre>
<genre genreId="0x23">Documentary/Documentaries</genre>
<genre genreId="0x24">Discussion/Interview/Debate</genre>
<!-- SHOW -->
<genre genreId="0x30">Show</genre>
<genre genreId="0x31">Game show/Quiz/Contest</genre>
<genre genreId="0x32">Variety show</genre>
<genre genreId="0x33">Talk show/Talk</genre>
<!-- SPORTS -->
<genre genreId="0x40">Sports/Short</genre>
<genre genreId="0x41">Special Events/Olympic Games/World Cup</genre>
<genre genreId="0x42">Sports Magazines</genre>
<genre genreId="0x43">Football/Soccer/American Football/Rugby</genre>
<genre genreId="0x44">Tennis/Squash</genre>
<genre genreId="0x45">Team sports</genre>
<genre genreId="0x46">Athletics</genre>
<genre genreId="0x47">Motor sport</genre>
<genre genreId="0x48">Water sport</genre>
<genre genreId="0x49">Winter sports</genre>
<genre genreId="0x4A">Equestrian</genre>
<genre genreId="0x4B">Martial sports</genre>
<!-- CHILDREN/YOUTH (Drk. Yellow) -->
<genre genreId="0x50">Children/Youth/Family/Kids</genre>
<genre genreId="0x51">Pre-school children</genre>
<genre genreId="0x52">Entertainment programmes for 6 to 14</genre>
<genre genreId="0x53">Entertainment programmes for 10 to 16</genre>
<genre genreId="0x54">Informational/Educational/School programmes/ Instructional</genre>
<genre genreId="0x55">Cartoons/Puppets/Animation</genre>
<!-- MUSIC/BALLET/DANCE (Green) -->
<genre genreId="0x60">Music/Dance</genre>
<genre genreId="0x61">Rock/Pop</genre>
<genre genreId="0x62">Serious/Classical</genre>
<genre genreId="0x63">Folk/Traditional</genre>
<genre genreId="0x64">Jazz</genre>
<genre genreId="0x65">Musical/Opera/Broadway</genre>
<genre genreId="0x66">Ballet</genre>
<!-- ARTS/CULTURE -->
<genre genreId="0x70">Arts/Culture</genre>
<genre genreId="0x71">Performing Arts</genre>
<genre genreId="0x72">Fine Arts</genre>
<genre genreId="0x73">Religion</genre>
<genre genreId="0x74">Popular Culture/Traditional Arts</genre>
<genre genreId="0x75">Literature</genre>
<genre genreId="0x76">Film/Cinema</genre>
<genre genreId="0x77">Experimental Film/Video</genre>
<genre genreId="0x78">Broadcasting/Press</genre>
<genre genreId="0x79">New Media</genre>
<genre genreId="0x7A">Arts/Culture Magazines</genre>
<genre genreId="0x7B">Fashion</genre>
<!-- SOCIAL/POLITICAL/ECONOMICS -->
<genre genreId="0x80">Social/Political Issues/Economics</genre>
<genre genreId="0x81">Magazines/Reports/Documentary</genre>
<genre genreId="0x82">Economics/Social Advisory</genre>
<genre genreId="0x83">Remarkable people</genre>
<!-- EDUCATIONAL/SCIENCE -->
<genre genreId="0x90">Education/Science/Factual Topics</genre>
<genre genreId="0x91">Nature/Animals/Environment</genre>
<genre genreId="0x92">Technology/Natural Sciences</genre>
<genre genreId="0x93">Medicine/Physiology/Psychology</genre>
<genre genreId="0x94">Foreign Countries/Expeditions</genre>
<genre genreId="0x95">Social/Spiritual Sciences</genre>
<genre genreId="0x96">Further Education</genre>
<genre genreId="0x97">Languages</genre>
<!-- LEISURE/HOBBIES -->
<genre genreId="0xA0">Leisure/Hobbies</genre>
<genre genreId="0xA1">Tourism/Travel</genre>
<genre genreId="0xA2">Handicraft</genre>
<genre genreId="0xA3">Motoring</genre>
<genre genreId="0xA4">Fitness and Health</genre>
<genre genreId="0xA5">Cooking</genre>
<genre genreId="0xA6">Advertisement/Shopping</genre>
<genre genreId="0xA7">Gardening</genre>
<!-- SPECIAL -->
<genre genreId="0xB0">Special Characteristics/Original Language</genre>
<genre genreId="0xB1">Black and White</genre>
<genre genreId="0xB2">Unpublished</genre>
<genre genreId="0xB3">Live/Live broadcast</genre>
<genre genreId="0xB4">Plano-stereoscopic</genre>
<genre genreId="0xB5">Local/Regional</genre>
<!-- USERDEFINED -->
<genre genreId="0xF0"></genre>
<genre genreId="0xF1"></genre>
<genre genreId="0xF2"></genre>
<genre genreId="0xF3"></genre>
<!-- IGNORED -->
<genre genreId="0xF4"></genre>
<genre genreId="0xF5"></genre>
<genre genreId="0xF6"></genre>
<genre genreId="0xF7"></genre>
<genre genreId="0xF8"></genre>
</genres>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema attributeFormDefault="unqualified" elementFormDefault="qualified" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="genres">
<xs:complexType>
<xs:sequence>
<xs:element name="name" type="xs:string" />
<xs:element maxOccurs="unbounded" name="genre">
<xs:complexType>
<xs:simpleContent>
<xs:extension base="xs:string">
<xs:attribute name="genreId" type="xs:string" use="required" />
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>

View File

@@ -0,0 +1,18 @@
<!--
Custom Channel Groups:
- Allows users to create a bespoke list of groups to load.
- For each name that matches a group/bouquet name from the set top box include it in the channels loaded
- If no names match the addon will load last scanned by default.
- channelGroupName is the only value to be set
If you are creating your own Custom Channel Groups file make a copy of this file in the same directory so it's not overwritten and start from there.
NOTE: IF YOU MODIFY THIS FILE IT WILL BE OVERWRITTEN NEXT TIME THE ADDON IS STARTED
-->
<customChannelGroups>
<channelGroupName>My 1st Provder - Sports</channelGroupName>
<channelGroupName>My 2nd Provder - Entertainment</channelGroupName>
<channelGroupName>My 3rd Provder - Movies</channelGroupName>
</customChannelGroups>

View File

@@ -0,0 +1,239 @@
{"":{},
"newyear" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Twilight Zone","Anthology","Anthologies","Outer Limits"]},
{"field":"plot" ,"operator":"contains","value":["Twilight Zone","Anthology","Anthologies","Outer Limits"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Twilight Zone","Anthology","Anthologies","Outer Limits"]},
{"field":"plot" ,"operator":"contains","value":["Twilight Zone","Anthology","Anthologies","Outer Limits"]}]}]}}]},
"scifi" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"genre" ,"operator":"contains","value":["Sci-Fi","Science Fiction"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"genre" ,"operator":"contains","value":["Sci-Fi","Science Fiction"]}]}]}}]},
"lotr" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Lord of the rings","LOTR","Hobbit","J.R.R. Tolkien","Tolkien"]},
{"field":"plot" ,"operator":"contains","value":["Lord of the rings","LOTR","Hobbit","J.R.R. Tolkien","Tolkien"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Lord of the rings","LOTR","Hobbit","J.R.R. Tolkien","Tolkien"]},
{"field":"plot" ,"operator":"contains","value":["Lord of the rings","LOTR","Hobbit","J.R.R. Tolkien","Tolkien"]}]}]}}]},
"lego" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["LEGO"]},
{"field":"plot" ,"operator":"contains","value":["LEGO"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["LEGO"]},
{"field":"plot" ,"operator":"contains","value":["LEGO"]}]}]}}]},
"gijoe" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["G.I. Joe"]},
{"field":"plot" ,"operator":"contains","value":["G.I. Joe"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["G.I. Joe"]},
{"field":"plot" ,"operator":"contains","value":["G.I. Joe"]}]}]}}]},
"romance" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"genre" ,"operator":"contains","value":["Romance","Romcom"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"genre" ,"operator":"contains","value":["Romance","Romcom"]}]}]}}]},
"pokemon" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Pokemon","Pokémon"]},
{"field":"plot" ,"operator":"contains","value":["Pokemon","Pokémon"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Pokemon","Pokémon"]},
{"field":"plot" ,"operator":"contains","value":["Pokemon","Pokémon"]}]}]}}]},
"superman" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Superman","Krypton"]},
{"field":"plot" ,"operator":"contains","value":["Superman","Krypton"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Superman","Krypton"]},
{"field":"plot" ,"operator":"contains","value":["Superman","Krypton"]}]}]}}]},
"pixar" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Pixar"]},
{"field":"plot" ,"operator":"contains","value":["Pixar"]},
{"field":"studio" ,"operator":"contains","value":["Pixar"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Pixar"]},
{"field":"plot" ,"operator":"contains","value":["Pixar"]},
{"field":"studio" ,"operator":"contains","value":["Pixar"]}]}]}}]},
"seuss" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Dr. Seuss","Lorax","Grinch"]},
{"field":"plot" ,"operator":"contains","value":["Dr. Seuss","Lorax","Grinch"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Dr. Seuss","Lorax","Grinch"]},
{"field":"plot" ,"operator":"contains","value":["Dr. Seuss","Lorax","Grinch"]}]}]}}]},
"hitchcock":{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Alfred Hitchcock"]},
{"field":"plot" ,"operator":"contains","value":["Alfred Hitchcock"]}]}]}}],
"movies" : [{"sort":{"method":"random", "order":"ascending"},"filter":{"and":[{"or" :[{"field":"director","operator":"contains","value":["Alfred Hitchcock"]},
{"field":"writers" ,"operator":"contains","value":["Alfred Hitchcock"]},
{"field":"plot" ,"operator":"contains","value":["Alfred Hitchcock"]}]}]}}]},
"patrick" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["It's Always Sunny"]}]}]}},
{"sort":{"method":"random" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"plot" ,"operator":"contains","value":["St. Patrick","Leprechaun","Irish","Luck","Lucky","Gold","Shamrock"]}]}]}}],
"movies" : [{"sort":{"method":"random" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"plot" ,"operator":"contains","value":["St. Patrick","Leprechaun","Irish","Luck","Lucky","Gold","Shamrock"]}]}]}}]},
"quentin" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"director","operator":"contains","value":["Quentin Tarantino"]},
{"field":"writers" ,"operator":"contains","value":["Quentin Tarantino"]},
{"field":"plot" ,"operator":"contains","value":["Quentin Tarantino"]}]}]}}],
"movies" : [{"sort":{"method":"random" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"director","operator":"contains","value":["Quentin Tarantino"]},
{"field":"writers" ,"operator":"contains","value":["Quentin Tarantino"]},
{"field":"plot" ,"operator":"contains","value":["Quentin Tarantino"]}]}]}}]},
"anderson" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"director","operator":"contains","value":["Wes Anderson"]},
{"field":"writers" ,"operator":"contains","value":["Wes Anderson"]},
{"field":"plot" ,"operator":"contains","value":["Wes Anderson"]}]}]}}],
"movies" : [{"sort":{"method":"random" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"director","operator":"contains","value":["Wes Anderson"]},
{"field":"writers" ,"operator":"contains","value":["Wes Anderson"]},
{"field":"plot" ,"operator":"contains","value":["Wes Anderson"]}]}]}}]},
"lucas" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"director","operator":"contains","value":["George Lucas","Sofia Coppola"]},
{"field":"writers" ,"operator":"contains","value":["George Lucas","Sofia Coppola"]},
{"field":"plot" ,"operator":"contains","value":["George Lucas","Sofia Coppola"]}]}]}}],
"movies" : [{"sort":{"method":"random" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"director","operator":"contains","value":["George Lucas","Sofia Coppola"]},
{"field":"writers" ,"operator":"contains","value":["George Lucas","Sofia Coppola"]},
{"field":"plot" ,"operator":"contains","value":["George Lucas","Sofia Coppola"]}]}]}}]},
"welles" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"director","operator":"contains","value":["Orson Welles"]},
{"field":"writers" ,"operator":"contains","value":["Orson Welles"]},
{"field":"plot" ,"operator":"contains","value":["Orson Welles"]}]}]}}],
"movies" : [{"sort":{"method":"random" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"director","operator":"contains","value":["Orson Welles"]},
{"field":"writers" ,"operator":"contains","value":["Orson Welles"]},
{"field":"plot" ,"operator":"contains","value":["Orson Welles"]}]}]}}]},
"chukwu" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"director","operator":"contains","value":["Chinonye Chukwu"]},
{"field":"writers" ,"operator":"contains","value":["Chinonye Chukwu"]},
{"field":"plot" ,"operator":"contains","value":["Chinonye Chukwu"]}]}]}}],
"movies" : [{"sort":{"method":"random" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"director","operator":"contains","value":["Chinonye Chukwu"]},
{"field":"writers" ,"operator":"contains","value":["Chinonye Chukwu"]},
{"field":"plot" ,"operator":"contains","value":["Chinonye Chukwu"]}]}]}}]},
"eastwood" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"director","operator":"contains","value":["Clint Eastwood"]},
{"field":"writers" ,"operator":"contains","value":["Clint Eastwood"]},
{"field":"cast" ,"operator":"contains","value":["Clint Eastwood"]},
{"field":"plot" ,"operator":"contains","value":["Clint Eastwood"]}]}]}}],
"movies" : [{"sort":{"method":"random" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"director","operator":"contains","value":["Clint Eastwood"]},
{"field":"writers" ,"operator":"contains","value":["Clint Eastwood"]},
{"field":"cast" ,"operator":"contains","value":["Clint Eastwood"]},
{"field":"plot" ,"operator":"contains","value":["Clint Eastwood"]}]}]}}]},
"startrek" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Star Trek"]},
{"field":"plot" ,"operator":"contains","value":["Star Trek"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Star Trek"]},
{"field":"plot" ,"operator":"contains","value":["Star Trek"]}]}]}}]},
"muppets" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["The Muppets"]},
{"field":"plot" ,"operator":"contains","value":["The Muppets"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["The Muppets"]},
{"field":"plot" ,"operator":"contains","value":["The Muppets"]}]}]}}]},
"othello" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["William Shakespeare","Shakespeare"]},
{"field":"writers" ,"operator":"contains","value":["William Shakespeare","Shakespeare"]},
{"field":"plot" ,"operator":"contains","value":["William Shakespeare","Shakespeare"]}]}]}}],
"movies" : [{"sort":{"method":"random" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["William Shakespeare","Shakespeare"]},
{"field":"writers" ,"operator":"contains","value":["William Shakespeare","Shakespeare"]},
{"field":"plot" ,"operator":"contains","value":["William Shakespeare","Shakespeare"]}]}]}}]},
"super" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"studio" ,"operator":"contains","value":["Marvel","DC"]},
{"field":"plot" ,"operator":"contains","value":["Superhero"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"studio" ,"operator":"contains","value":["Marvel","DC"]},
{"field":"plot" ,"operator":"contains","value":["Superhero"]}]}]}}]},
"aliens" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"is" ,"value":["Alien: Earth"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"is" ,"value":["TED 2023","Prometheus","Alien: Covenant","Alien","Alien: Isolation","Alien: Out of the Shadows","Alien: Romulus","Aliens","Aliens: Colonial Marines","Fire and Stone","Alien3","Alien³","Aliens: Dark Descent","Aliens: Fireteam Elite","Aliens: Phalanx","Alien Resurrection"]},
{"field":"title" ,"operator":"is" ,"value":["Predator","Predator 2","Predators","The Predator","Prey","Badlands","Alien vs. Predator","Aliens vs. Predator: Requiem","Predator: Dark Ages"]}]}]}}]},
"starwars" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Star Wars"]},
{"field":"plot" ,"operator":"contains","value":["Star Wars"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Star Wars"]},
{"field":"plot" ,"operator":"contains","value":["Star Wars"]}]}]}}]},
"twilight" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Twilight Zone", "Rod Serling"]},
{"field":"plot" ,"operator":"contains","value":["Twilight Zone", "Rod Serling"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Twilight Zone", "Rod Serling"]},
{"field":"plot" ,"operator":"contains","value":["Twilight Zone", "Rod Serling"]}]}]}}]},
"mavrick" :{"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"is","value":["Top Gun","Top Gun: Maverick"]}]}]}}]},
"watson" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Enola Holmes", "Sherlock", "Sher-lock", "Sherlock Holmes", "Arthur Conan Doyle", "Watson"]},
{"field":"plot" ,"operator":"contains","value":["Enola Holmes", "Sherlock", "Sher-lock", "Sherlock Holmes", "Arthur Conan Doyle", "Watson"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Enola Holmes", "Sherlock", "Sher-lock", "Sherlock Holmes", "Arthur Conan Doyle", "Watson"]},
{"field":"plot" ,"operator":"contains","value":["Enola Holmes", "Sherlock", "Sher-lock", "Sherlock Holmes", "Arthur Conan Doyle", "Watson"]}]}]}}]},
"vampire" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Vampire", "Dracula" ,"Nosferatu"]},
{"field":"plot" ,"operator":"contains","value":["Vampire", "Dracula" ,"Nosferatu", "Vamp", "Bloodsucker", "Vampirism"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Vampire", "Dracula" ,"Nosferatu"]},
{"field":"plot" ,"operator":"contains","value":["Vampire", "Dracula" ,"Nosferatu", "Vamp", "Bloodsucker", "Vampirism"]}]}]}}]},
"ghosts" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Ghostbusters"]},
{"field":"plot" ,"operator":"contains","value":["Ghostbusters"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Ghostbusters"]},
{"field":"plot" ,"operator":"contains","value":["Ghostbusters"]}]}]}}]},
"jurassic" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Jurassic Park"]},
{"field":"plot" ,"operator":"contains","value":["Jurassic Park"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Jurassic Park"]},
{"field":"plot" ,"operator":"contains","value":["Jurassic Park"]}]}]}}]},
"disney" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Disney","Walt Disney"]},
{"field":"plot" ,"operator":"contains","value":["Disney","Walt Disney"]},
{"field":"studio" ,"operator":"contains","value":["Disney","Walt Disney"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Disney","Walt Disney"]},
{"field":"plot" ,"operator":"contains","value":["Disney","Walt Disney"]},
{"field":"studio" ,"operator":"contains","value":["Disney","Walt Disney"]}]}]}}]},
"potter" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Harry Potter"]},
{"field":"plot" ,"operator":"contains","value":["Harry Potter"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Harry Potter","Fantastic beasts"]},
{"field":"plot" ,"operator":"contains","value":["Harry Potter","Fantastic beasts"]}]}]}}]},
"spider" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Spider Man","Spider-Man","Spiderman"]},
{"field":"plot" ,"operator":"contains","value":["Spider Man","Spider-Man","Spiderman"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Spider Man","Spider-Man","Spiderman"]},
{"field":"plot" ,"operator":"contains","value":["Spider Man","Spider-Man","Spiderman"]}]}]}}]},
"sponge" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["SpongeBob"]},
{"field":"plot" ,"operator":"contains","value":["SpongeBob"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["SpongeBob"]},
{"field":"plot" ,"operator":"contains","value":["SpongeBob"]}]}]}}]},
"ranger" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Power Rangers"]},
{"field":"plot" ,"operator":"contains","value":["Power Rangers"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Power Rangers"]},
{"field":"plot" ,"operator":"contains","value":["Power Rangers"]}]}]}}]},
"batman" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Batman"]},
{"field":"plot" ,"operator":"contains","value":["Batman"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Batman","Joker"]},
{"field":"plot" ,"operator":"contains","value":["Batman"]}]}]}}]},
"wonka" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Willy Wonka","Wonka"]},
{"field":"plot" ,"operator":"contains","value":["Willy Wonka","Wonka"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Willy Wonka","Wonka"]},
{"field":"plot" ,"operator":"contains","value":["Willy Wonka","Wonka"]}]}]}}]},
"future" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Back to the future","Delorean"]},
{"field":"plot" ,"operator":"contains","value":["Back to the future","Delorean"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Back to the future","Delorean"]},
{"field":"plot" ,"operator":"contains","value":["Back to the future","Delorean"]}]}]}}]},
"horror" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"genre" ,"operator":"contains","value":["Horror"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"genre" ,"operator":"contains","value":["Horror"]}]}]}}]},
"heros" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Veterans","WWI","WWII","Korean War","Vietnam War","World War I","World War II"]},
{"field":"plot" ,"operator":"contains","value":["Veterans","WWI","WWII","Korean War","Vietnam War","World War I","World War II"]},
{"field":"genre" ,"operator":"contains","value":["War"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Veterans","WWI","WWII","Korean War","Vietnam War","World War I","World War II"]},
{"field":"plot" ,"operator":"contains","value":["Veterans","WWI","WWII","Korean War","Vietnam War","World War I","World War II"]},
{"field":"genre" ,"operator":"contains","value":["War"]}]}]}}]},
"tardis" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"is" ,"value":["Dr. Who","Doctor Who","Tardis"]},
{"field":"plot" ,"operator":"is" ,"value":["Dr. Who","Doctor Who","Tardis"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"is" ,"value":["Dr. Who","Doctor Who","Tardis"]},
{"field":"plot" ,"operator":"is" ,"value":["Dr. Who","Doctor Who","Tardis"]}]}]}}]},
"turkey" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Thanksgiving","Turkey"]},
{"field":"plot" ,"operator":"contains","value":["Thanksgiving","Turkey"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Thanksgiving","Turkey"]},
{"field":"plot" ,"operator":"contains","value":["Thanksgiving","Turkey"]}]}]}}]},
"marvel" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Marvel"]},
{"field":"plot" ,"operator":"contains","value":["Marvel"]},
{"field":"studio" ,"operator":"contains","value":["Marvel"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Marvel"]},
{"field":"plot" ,"operator":"contains","value":["Marvel"]},
{"field":"studio" ,"operator":"contains","value":["Marvel"]}]}]}}]},
"xmas" :{"episodes": [{"sort":{"method":"episode","order":"ascending"},"filter":{"and":[{"or" :[{"field":"tvshow" ,"operator":"contains","value":["Christmas","X-Mas","Christmastime","Hanukkah","Kwanzaa"]},
{"field":"plot" ,"operator":"contains","value":["Christmas","X-Mas","Christmastime","Hanukkah","Kwanzaa"]}]}]}}],
"movies" : [{"sort":{"method":"year" ,"order":"ascending"},"filter":{"and":[{"or" :[{"field":"title" ,"operator":"contains","value":["Christmas","X-Mas","Christmastime","Hanukkah","Kwanzaa"]},
{"field":"plot" ,"operator":"contains","value":["Christmas","X-Mas","Christmastime","Hanukkah","Kwanzaa"]}]}]}}]}}

View File

@@ -0,0 +1,26 @@
{
"uuid": "",
"library": {
"Item": {
"enabled": false,
"type": "",
"name": "",
"logo": "",
"path": [],
"rules": {}
},
"TV Networks": [],
"TV Shows": [],
"TV Genres": [],
"Movie Genres": [],
"Movie Studios": [],
"Mixed Genres": [],
"Mixed": [],
"Playlists": [],
"Recommended": [],
"Services": [],
"Music Genres": []
},
"whitelist": [],
"blacklist": []
}

View File

@@ -0,0 +1,45 @@
{
"item": {
"id": "",
"number": 0,
"name": "",
"logo": "",
"group": [
],
"catchup": "vod",
"radio": false,
"favorite": false,
"realtime": false,
"media": "",
"label": "",
"url": "",
"tvg-shift": "",
"x-tvg-url": "",
"media-dir": "",
"media-size": "",
"media-type": "",
"catchup-source": "",
"catchup-days": "",
"catchup-correction": "",
"provider": "",
"provider-type": "",
"provider-logo": "",
"provider-countries": "",
"provider-languages": "",
"x-playlist-type": "",
"kodiprops": [
]
},
"required": {
"id": "",
"number": 0,
"name": "",
"logo": "",
"group": [
],
"catchup": "vod",
"radio": false,
"label": "",
"url": ""
}
}

View File

@@ -0,0 +1,146 @@
{"Blues":"Blues developed in the 19th century and was originally played by a single performer singing with a guitar or banjo. By the 1960s, The Blues had evolved significantly along with the instruments used (now electric guitars, bass and drums) and made its way across the Atlantic to the UK and beyond. A common feature of Blues music is the 12-bar blues chord structure. This starts with 4 bars on the root note of the scale followed by two on the 4th. This is then followed by two on the root, one bar on the 5th, one bar on the 4th and another two on the root.",
"Classical":"Encompassing a huge range of sub-genres, classical music refers broadly to most orchestral styles between 1750 and 1820. It came as a reaction to the rules and restrictions prevalent in baroque music which predates it. To many people, anything pre-jazz sounds classical and may be referred to as such. Once you get inside this genre, however, you will find whole-world music and a stunning range of styles and categories.",
"Country":"Also known as country and western, country music has its roots in the south of the USA. Having evolved from a combination of different fold styles. Combining the influence of working-class immigrants it takes its cues from Irish and Celtic folk, traditional English ballads and cowboy songs. In the modern era, there are numerous sub-genres like country pop, country rock and neo-country.",
"Dance":"Dance music is a far more modern genre that could also be broadly categorized as electronic music. With roots in disco music combined with the evolution of pop music, electronic dance music took off in the late 1980s and early 90s. It is now home to an incredibly large number of sub-genres, some of which have become popular enough to be considered full genres in their own right.",
"Drill":"Havent heard of drill music? Dont worry youre not alone yet. With this being a growing underground genre, its making its way into the medias consciousness with a whole lot of controversy. Drill music is an aggressive music form taking its cues from grime, rap and dance music. Characterised by its own beat patterns, the lyrics feature what is often extreme violence and talk of criminal acts. As a result, theres been police interventions and YouTube bans some acts have to now get their videos authorised before being allowed to post. While it originated in the USA, its taken on a new British format mostly in South London where its growing.",
"Jungle":"A direct result of the dance music scene, drum and bass became a fully-fledged genre of its own. Characterised by high BPM drums and heavy bass lines, it borrows heavily from other genres. The drum and bass characteristics are significant enough for most people to be able to spot and categorise this genre quite easily.",
#7 Dubstep
Yet another love-child of the dance genre, dubstep is an evolution of garage and drum and bass. It came to prominence in the early 2000s, borrowing the heavy bass lines and distorted tones used in drum and bass.
Dubstep combined garage timing and urban influences to create an extremely energetic and popular genre of music.
#8 Easy Listening
Based more on mood rather than any particular musical traits, easy listening tends to omit vocal performances in favour of easy-going re-workings of popular pop and rock hits.
Coming to prominence in the 1970s the genre has perhaps gone through a bit of re-brand in the form of chill-out music.
#9 Electronic Dance Music (EDM)
We mentioned that dance has a lot of sub-genres, but this is its biggest. Its the fastest-growing music type across the world and rose in popularity with DJs like David Guetta, Calvin Harris and Tiesto leading the way. Its closely linked to House music and came about as disco declined.
Originally a cult and underground movement, EDM is now mainstream and while part of the dance music umbrella is very much its own entity. EDM festivals, big-name DJ gigs and Vegas events ensure it continues to be successful.
#10 Emo
With roots in rock, pop, heavy metal and punk, emo music has a specific goal in that it is designed to have a particularly emotive or emotional resonance.
Characterised by expressive melodic musicianship and often confessional lyrics. It is often associated with a particular fashion style that is also influenced by metal and punk.
#11 Funk
Funk uses a syncopated beat and heavy bass lines and distinctive grooves. It originates from African American influences and takes cues from Soul, Jazz and R&B.
Since rising to prominence in the 1960s, it has gone on to influence almost every genre of dance music as well as modern rock.
#12 Folk
Folk is a very traditional genre. Traditional folk music is orally passed down over time and often has no author. However, modern artists can still be labelled as Folk artists with their original songs.
Storytelling is a key aspect of folk music and whilst musical styles vary across the world, this is a consistent element.
#13 Garage
Another modern genre that has come directly from the evolution of electronic dance music, drum and bass and soul/R&B. Heavy baselines, irregular kick drum patterns and syncopated hi-hats are all standard characterisations for this popular style.
#14 Grunge
Grunge music is based on rock and punk and was popularised in the 1990s by bands like Nirvana and Pearl Jam. Played in a traditional rock band set up with electric guitars and bass, distorted guitars the main feature was a more anguished vocal style and perhaps a more negative outlook on life.
#15 Grime
This British take on hip-hop fuses the dubstep, garage, dancehall and drum and bass influences with rap and R&B.
Gaining notoriety through pirate radio stations in London, it has continuously evolved to incorporate more sounds and rhythms, gaining massive popularity in the process.
#16 Hip Hop
Now an extremely broad musical category, hip hop evolved out of a cultural explosion in the United States. Featuring vinyl records mixed on turntables and incorporating the rap genre along with heavy bass-lines and samples, hip hop has now become extremely significant in terms of musics cultural influence in modern times.
#17 House
House Music
Linked to EDM and trance, House music began in a USA nightclub called The Warehouse in the late 1970s. Its defined by a gradual build-up to a crescendo followed by a euphoric drop in the beat. To some extent, EDM has taken over from house music which had a peak in the 1990s and 2000s, but its still going strong, especially in places like Ibiza. It uses an addictively repetitious four-on-the-floor beat and a tempo of 120 to 130 beats per minute. A well-known sub-genre of house music is acid house.
#18 Indie
Another offshoot from rock and punk, indie music came from so-called independent artists and bands who were not part of the mainstream music industry machine. The style of indie music has typically remained with a primary rock band set-up.
However, it has evolved from a blend of punk, and rock to include modern electronic and dance music.
#19 Jazz
Historically started in New Orleans in the early 1900s, Jazz typifies musical flexibility not seen by many other genres. Featuring a mix of rhythms and tempos as well as a focus on soloing, jazz also has a huge range of potential instrumental structures and setups.
Its very common for there to be a drummer and a bass instrument as well as any number of lead instruments ranging from woodwind to stringed instruments.
Popular genres
#20 K-Pop
Remember Gangnam Style? This new genre of music was initially categorised as a brand, rather than a type of music. But its explosion not only in its native South Korea but in the Western world has elevated its status. It borrows a variety of forms, including pop, electronic music, rap, R&B and even classical music. Lady Gaga recently collaborated with a fledgeling K-Pop star Rose cementing its place in the US mainstream.
#21 Latin
Latin music refers to Latin America and the influence of the whole of South America on various styles. This genre of music has Spanish and African roots but was popularised in the United States in the 20th century by Hispanic and Latino immigrants.
Latin music is very percussive and driven by energy, passion, polyrhythms and movement.
#22 Metal
Metal, or Heavy Metal, is a sub-genre of rock music that has become a genre in its own right and spawned countless other sub-genres. Featuring a band setup with electric guitars, bass and drums, the distorted guitar sounds to give it the heavy non-commercialised and aggressive sound.
Fast tempos and shrieking vocals have become synonymous with the style.
#23 Motown
This particular genre is extremely interesting because it was the creation of Motown Records, a subsidiary of Universal, that began what would become a fully-fledged musical genre.
Best described as a pop-soul hybrid, the acts signed to the record label created a sound that would become a movement and eventually a genre in its own right.
#24 Mod
Mod or modernist music came to prominence and popularity in the working-class communities of the UK in the 1960s. Modern jazz and the Northern Soul were strong influences on Mod music.
It can also be characterised as a lifestyle or subculture and was popularised by the film Quadrophenia.
#25 Opera
A key part of the classical music tradition in the West, opera features vocal performances that make up a specific type of musical theatre. Opera is essentially a story told to music.
The lines between opera and classical music are extremely blurred and very often the two genres overlap.
#26 Pop
Pop music or popular music is an ever-evolving genre that encompasses any music that is designed for the masses. Anything played on mainstream radio can be categorised as pop.
Over the years pop has enveloped almost every genre from Motown to metal, hip-hop to drum and bass.
#27 Punk
Punk is another British music genre with roots in sub-culture development. The punk-rock scene was characterised by heavy, fast guitars, simplistic songs and basic recording techniques. Punk became incredibly iconic because of its at the time radical image and lyrical themes.
These themes and aesthetics went on to influence entire genres of music in the future.
#28 Rap
Rap describes a style of vocal delivery. However, it can be rightly regarded as a musical genre due to its massive popularity. Developing alongside hip-hop in the United States, rap evolved from MCs toasting and deejaying in Jamaican dancehall music.
It has grown to incorporate increasingly complex rhyme schemes and has been appreciated in the same regard as poetry.
#29 Reggae
Originating in Jamaica in the 1960s and taking the world by storm through the work of Bob Marley, reggae is a fusion of traditional Jamaican folk music with jazz and R&B.
Offbeat rhythms and staccato chords are common musical themes, and Reggae is closely linked to Rastafarianism and Afrocentric religion.
#30 Rhythm and Blues (R&B)
Another extremely interesting genre that has undergone several different shifts in focus. Originating in African American communities in the 1940s, it was popularized in the 1950s and the term was applied to blues records. In the 1970s the term was used to describe soul and funk.
However, its latest incarnation has blended with hip-hop and a whole range of other styles. From the 1980s to the present day it has described popular soulful artists, from Maria Carey and Whitney Houston to SZA and Jorja Smith.
#31 Rock
Arising from the evolution of the electric guitar and distorted amplification, rock music is now home to hundreds of sub-genres. Popularised in both the UK and the United States by bands playing a 4/4 rhythm and singing verse-chorus songs, it has become part of music history.
Rocknroll music is characterised by guitars and a heavy snare and a kick drum rhythm.
#32 Soul
Another genre that came from African American roots, soul music is an evolution from original rhythm and blues, gospel and jazz.
Featuring hand claps, call-and-response singing, and heavy focus on lead singers, Soul became so popular it eventually began to splinter into other genres, like Motown.
#33 Techno
Techno-music is a direct descendant of the dance music genre. It differentiates itself by having a much higher tempo and kick 4/4 kick drum lead beat.
#34 Trance
Another offshoot of electronic dance music, trance features heavily synthesised lead lines that have to induce a trance-like state in dancers. The euphoric nature of techno music is meant to take listeners on a journey.
#35 World
This is a huge genre that encompasses a localised version of traditional music from all over the globe. Each country has rhythmic and melodic nuances that set them apart.
The term World music can be used to describe all of them, but each countrys music has its own names and even sub-genres.

View File

@@ -0,0 +1,30 @@
<!--
Custom Providers:
- Allows users to create a bespoke list of providers to map to.
- For each provider name that matches a provider name below the given name, type,
icon, country codes and lanuage codes will be used.
- If no names match the addon will just use the supplied name and any other metadta supplied
in the M3U for the provider.
- The valid values for types are: unknown, addon, satellite, cable, aerial and iptv
- Country codes should be ISO 3166 codes, comma separated (e.g 'GB,IE,FR,CA'),
an empty string means this value is undefined.
- Language codes should be RFC 5646 codes, comma separated (e.g. 'en_GB,fr_CA'),
an empty string means this value is undefined.
If you have publicly available icons for providers and would like to make them available as default please
create an issue at https://github.com/kodi-pvr/pvr.iptvsimple/issues requesting their inclusion.
If you are creating your own Custom Providers file make a copy of this file in the same directory so it's
not overwritten and start from there.
NOTE: IF YOU MODIFY THIS FILE IT WILL BE OVERWRITTEN NEXT TIME THE ADDON IS STARTED
-->
<providerMappings>
<providerMapping mappedName="PseudoTV">
<name>PseudoTV Live</name>
<type>local</type>
<iconPath>https://github.com/PseudoTV/PseudoTV_Live/raw/master/plugin.video.pseudotv.live/resources/skins/default/media/logo.png</iconPath>
</providerMapping>
</providerMappings>

View File

@@ -0,0 +1,6 @@
{
"id": 0,
"values": {
"0": ""
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"servers": {
"Example": {
"id": "",
"version": "",
"uuid": "",
"name": "",
"host": "",
"remotes": {
},
"settings": {
},
"enabled": false,
"online": false,
"updated": null
}
}
}

View File

@@ -0,0 +1,557 @@
<!-- DTD for TV listings
This is a DTD to represent a TV listing. It doesn't explicitly group
programmes by day or by channel, instead broadcast time and channel
are attributes of the 'programme' element. Optionally, data about the
TV channels used can be stored in 'channel' elements.
Data about a TV programme are stored in the subelements of element
'programme', but metadata such as when it will be broadcast are stored
as attributes.
Many of the details have a 'lang' attribute so that you can
store them in multiple languages or have mixed languages in a single
listing. This 'lang' should be the two-letter code such as 'en' or
'fr_FR'. Or you can just leave it out and let your reader take a
guess.
Unless otherwise specified, an element containing CDATA must have some
text if it is written.
An example XML file for this DTD might look like this:
<tv generator-info-name="my listings generator">
<channel id="3sat.de">
<display-name lang="de">3SAT</display-name>
</channel>
<channel id="das-erste.de">
<display-name lang="de">ARD</display-name>
<display-name lang="de">Das Erste</display-name>
</channel>
<programme start="200006031633" channel="3sat.de">
<title lang="de">blah</title>
<title lang="en">blah</title>
<desc lang="de">
Blah Blah Blah.
</desc>
<credits>
<director>blah</director>
<actor>a</actor>
<actor>b</actor>
</credits>
<date>19901011</date>
<country>ES</country>
<episode-num system="xmltv_ns">2 . 9 . 0/1</episode-num>
<video>
<aspect>16:9</aspect>
</video>
<rating system="MPAA">
<value>PG</value>
<icon src="pg_symbol.png" />
</rating>
<star-rating>
<value>3/3</value>
</star-rating>
</programme>
<programme> ... </programme>
...
</tv>
This describes two channels and then a programme broadcast on one of
the channels, then some more programmes. Almost everything in the DTD
is optional, so you can write files which are much simpler than this
example.
All dates and times in this DTD follow the same format, loosely based
on ISO 8601. They can be 'YYYYMMDDhhmmss' or some initial
substring, for example if you only know the year and month you can
have 'YYYYMM'. You can also append a timezone to the end; if no
explicit timezone is given, UTC is assumed. Examples:
'200007281733 BST', '200209', '19880523083000 +0300'. (BST == +0100.)
Unless specified otherwise, textual element content may not contain
newlines - this is to make it easy to convert into line-oriented
formats, and to avoid the question of what exactly a newline would
mean in the middle of someone's name or whatever. Leading and
trailing whitespace in element content is not significant.
At present versions of this DTD correspond to releases of the 'xmltv'
package, which is a set of programs to generate and manipulate files
conforming to this DTD. Written by Ed Avis (ed@membled.com) and
Gottfried Szing, thanks to others for suggestions.
$Id: xmltv.dtd,v 1.44 2010/04/10 13:11:06 knowledgejunkie Exp $
-->
<!-- The root element, tv.
Date should be the date when the listings were originally produced in
whatever format; if you're converting data from another source, then
use the date given by that source. The date when the conversion
itself was done is not important.
To indicate the source of the listings, there are three attributes you
can define:
'source-info-url' is a URL describing the data source in
some human-readable form. So if you are getting your listings from
SAT.1, you might set this to the URL of a page explaining how to
subscribe to their feed. If you are getting them from a website, the
URL might be the index of the site or at least of the TV listings
section.
'source-info-name' is the link text for that URL; it should
generally be the human-readable name of your listings supplier.
Sometimes the link text might be printed without the link itself, in
hardcopy listings for example.
'source-data-url' is where the actual data is grabbed from. This
should link directly to the machine-readable data files if possible,
but it's not rigorously defined what 'actual data' means. If you are
parsing the data from human-readable pages, then it's more appropriate
to link to them with the source-info stuff and omit this attribute.
To publicize your wonderful program which generated this file, you can
use 'generator-info-name' (preferably in the form 'progname/version')
and 'generator-info-url' (a link to more info about the program).
-->
<!ELEMENT tv (channel*, programme*)>
<!ATTLIST tv date CDATA #IMPLIED
source-info-url CDATA #IMPLIED
source-info-name CDATA #IMPLIED
source-data-url CDATA #IMPLIED
generator-info-name CDATA #IMPLIED
generator-info-url CDATA #IMPLIED >
<!-- channel - details of a channel
Each 'programme' element (see below) should have an attribute
'channel' giving the channel on which it is broadcast. If you want to
provide more detail about channels, you can give some 'channel'
elements before listing the programmes. The 'id' attribute of the
channel should match what is given in the 'channel' attribute of the
programme.
Typically, all the channels used in a particular TV listing will be
included and then the programmes using those channels. But it's
entirely optional to include channel details - you can just leave out
channel elements or provide only some of them. It is also okay to
give just channels and no programmes, if you just want to describe
what TV channels are available in a certain area.
Each channel has one id attribute, which must be unique and should
preferably be in the form suggested by RFC2838 (the 'broadcast'
element of the grammar in that RFC, in other words, a DNS-like name
but without any URI scheme). Then one or more display names which are
shown to the user. You might want a different display name for
different languages, but also you can have more than one name for the
same language. Names listed earlier are considered 'more canonical'.
Since the display name is just there as a way for humans to refer to
the channel, it's acceptable to just put the channel number if it's
fairly universal among viewers of the channel. But remember that this
isn't an official statement of what channel number has been
allocated, and the same number might be used for a different channel
somewhere else.
The ordering of channel elements makes no difference to the meaning of
the file, since they are looked up by id and not by their position.
However it makes things like diffing easier if you write the channel
elements sorted by ASCII order of their ids.
-->
<!ELEMENT channel (display-name+, icon*, url*) >
<!ATTLIST channel id CDATA #REQUIRED >
<!-- A user-friendly name for the channel - maybe even a channel
number. List the most canonical / common ones first and the most
obscure names last. The lang attribute follows RFC 1766.
-->
<!ELEMENT display-name (#PCDATA)>
<!ATTLIST display-name lang CDATA #IMPLIED>
<!-- A URL where you can find out more about the element that contains
it (programme or channel). This might be the official site, or a fan
page, whatever you like really.
If multiple url elements are given, the most authoritative or official
(which might conflict...) sites should be listed first.
-->
<!ELEMENT url (#PCDATA)>
<!-- programme - details of a single programme transmission
A show will be exactly the same whether it is broadcast at 18:00 or
19:00, and on whichever channel. Technical details like broadcast
time don't affect the content of the programme itself, so they are
included as attributes of this element. Start time and channel are
the two that you must include.
Sometimes VCR programming systems like PDC or VPS have their own
notion of 'start time' which is different from the actual start time,
so there are attributes for that. In practice, stop time will usually
be the start time of the next programme, but if you can get it more
accurate, good for you. Similarly, you can specify a code for
Gemstar's Showview or VideoPlus programming systems.
TV listings sometimes have the problem of listing two or more
programmes in the same timeslot, such as 'News; Weather'. We call
this a 'clump' of programmes, and the 'clumpidx' attribute
differentiates between two programmes sharing the same timeslot and
channel. In this case News would have clumpidx="0/2" and Weather
would have clumpidx="1/2". If you don't have this problem, be
thankful!
It's intended that start time and stop time, when both are present,
make a half-closed interval: a programme is considered to be
broadcasting _at_ its start time but to stop just before its stop
time. In this way a programme from 11:00 to 12:00 does not overlap
with another programme from 12:00 to 13:00, not even for a moment.
Nor is there any gap between the two.
To do: Some means of indicating breaks between programmes on the same
channel. The 'channel' attribute references the 'id' of a channel
element, but the DTD doesn't give a way to specify this constraint.
Perhaps there is some better XML syntax we could use for that.
-->
<!ELEMENT programme (title+, sub-title*, desc*, credits?, date?,
category*, language?, orig-language?, length?,
icon*, url*, country*, episode-num*, video?, audio?,
previously-shown?, premiere?, last-chance?, new?,
subtitles*, rating*, star-rating*, review* )>
<!ATTLIST programme start CDATA #REQUIRED
stop CDATA #IMPLIED
pdc-start CDATA #IMPLIED
vps-start CDATA #IMPLIED
showview CDATA #IMPLIED
videoplus CDATA #IMPLIED
channel CDATA #REQUIRED
clumpidx CDATA "0/1" >
<!-- Programme title, eg 'The Simpsons'. -->
<!ELEMENT title (#PCDATA)>
<!ATTLIST title lang CDATA #IMPLIED>
<!-- Sub-title or episode title, eg 'Datalore'. Should probably be
called 'secondary title' to avoid confusion with captioning!
-->
<!ELEMENT sub-title (#PCDATA)>
<!ATTLIST sub-title lang CDATA #IMPLIED>
<!-- Description of the programme or episode.
Unlike other elements, long bits of whitespace here are treated as
equivalent to a single space and newlines are permitted, so you can
break lines and write a pretty-looking paragraph if you wish.
-->
<!ELEMENT desc (#PCDATA)>
<!ATTLIST desc lang CDATA #IMPLIED>
<!-- Credits for the programme.
People are listed in decreasing order of importance; so for example
the starring actors appear first followed by the smaller parts. As
with other parts of this file format, not mentioning a particular
actor (for example) does not imply that he _didn't_ star in the film -
so normally you'd list only the few most important people.
Adapter can be either somebody who adapted a work for television, or
somebody who did the translation from another language. Maybe these
should be separate, but if so how would 'translator' fit in with the
'language' element?
-->
<!ELEMENT credits (director*, actor*, writer*, adapter*, producer*,
composer*, editor*, presenter*, commentator*,
guest* )>
<!ELEMENT director (#PCDATA)>
<!ELEMENT actor (#PCDATA)>
<!ATTLIST actor role CDATA #IMPLIED>
<!ELEMENT writer (#PCDATA)>
<!ELEMENT adapter (#PCDATA)>
<!ELEMENT producer (#PCDATA)>
<!ELEMENT composer (#PCDATA)>
<!ELEMENT editor (#PCDATA)>
<!ELEMENT presenter (#PCDATA)>
<!ELEMENT commentator (#PCDATA)>
<!ELEMENT guest (#PCDATA)>
<!-- The date the programme or film was finished. This will probably
be the same as the copyright date.
-->
<!ELEMENT date (#PCDATA)>
<!-- Type of programme, eg 'soap', 'comedy' or whatever the
equivalents are in your language. There's no predefined set of
categories and it's okay for a programme to belong to several.
-->
<!ELEMENT category (#PCDATA)>
<!ATTLIST category lang CDATA #IMPLIED>
<!-- The language the programme will be broadcast in. This does not
include the language of any subtitles, but it is affected by dubbing
into a different language. For example, if a French film is dubbed
into English, language=en and orig-language=fr.
There are two ways to specify the language. You can use the
two-letter codes such as en or fr, or you can give a name such as
'English' or 'Deutsch'. In the latter case you might want to use the
'lang' attribute, for example
<language lang="fr">Allemand</language>
-->
<!ELEMENT language (#PCDATA)>
<!ATTLIST language lang CDATA #IMPLIED>
<!-- The original language, before dubbing. The same remarks as for
'language' apply.
-->
<!ELEMENT orig-language (#PCDATA)>
<!ATTLIST orig-language lang CDATA #IMPLIED>
<!-- The true length of the programme, not counting advertisements or
trailers. But this does take account of any bits which were cut out
of the broadcast version - eg if a two hour film is cut to 110 minutes
and then padded with 20 minutes of advertising, length will be 110
minutes even though end time minus start time is 130 minutes.
-->
<!ELEMENT length (#PCDATA)>
<!ATTLIST length units (seconds | minutes | hours) #REQUIRED>
<!-- An icon associated with the element that contains it.
src: uri of image
width, height: (optional) dimensions of image
These dimensions are pixel dimensions for the time being, eventually
this will change to be more like HTML's 'img'.
-->
<!ELEMENT icon EMPTY>
<!ATTLIST icon src CDATA #REQUIRED
width CDATA #IMPLIED
height CDATA #IMPLIED>
<!-- The value of the element that contains it. This is for elements
that can have both a textual 'value' and an icon. At present there is
no 'lang' attribute here because things like 'PG' are not translatable
(although a document explaining what 'PG' actually means would be).
It happens that 'value' is used only for this sort of thing.
-->
<!ELEMENT value (#PCDATA)>
<!-- A country where the programme was made or one of the countries in
a joint production. You can give the name of a country, in which case
you might want to specify the language in which this name is written,
or you can give a two-letter uppercase country code, in which case the
lang attribute should not be given. For example,
<country lang="en">Italy</country>
<country>GB</country>
-->
<!ELEMENT country (#PCDATA)>
<!ATTLIST country lang CDATA #IMPLIED>
<!-- Episode number
Not the title of the episode, its number or ID. There are several
ways of numbering episodes, so the 'system' attribute lets you specify
which you mean.
There are two predefined numbering systems, 'xmltv_ns' and
'onscreen'.
xmltv_ns: This is intended to be a general way to number episodes and
parts of multi-part episodes. It is three numbers separated by dots,
the first is the series or season, the second the episode number
within that series, and the third the part number, if the programme is
part of a two-parter. All these numbers are indexed from zero, and
they can be given in the form 'X/Y' to show series X out of Y series
made, or episode X out of Y episodes in this series, or part X of a
Y-part episode. If any of these aren't known they can be omitted.
You can put spaces whereever you like to make things easier to read.
(NB 'part number' is not used when a whole programme is split in two
for purely scheduling reasons; it's intended for cases where there
really is a 'Part One' and 'Part Two'. The format doesn't currently
have a way to represent a whole programme that happens to be split
across two or more timeslots.)
Some examples will make things clearer. The first episode of the
second series is '1.0.0/1' . If it were a two-part episode, then the
first half would be '1.0.0/2' and the second half '1.0.1/2'. If you
know that an episode is from the first season, but you don't know
which episode it is or whether it is part of a multiparter, you could
give the episode-num as '0..'. Here the second and third numbers have
been omitted. If you know that this is the first part of a three-part
episode, which is the last episode of the first series of thirteen,
its number would be '0 . 12/13 . 0/3'. The series number is just '0'
because you don't know how many series there are in total - perhaps
the show is still being made!
The other predefined system, onscreen, is to simply copy what the
programme makers write in the credits - 'Episode #FFEE' would
translate to '#FFEE'.
You are encouraged to use one of these two if possible; if xmltv_ns is
not general enough for your needs, let me know. But if you want, you
can use your own system and give the 'system' attribute as a URL
describing the system you use.
-->
<!ELEMENT episode-num (#PCDATA)>
<!ATTLIST episode-num system CDATA "onscreen">
<!-- Video details: the subelements describe the picture quality as
follows:
present: whether this programme has a picture (no, in the
case of radio stations broadcast on TV or 'Blue'), legal values are
'yes' or 'no'. Obviously if the value is 'no', the other elements are
meaningless.
colour: 'yes' for colour, 'no' for black-and-white.
aspect: The horizontal:vertical aspect ratio, eg '4:3' or '16:9'.
quality: information on the quality, eg 'HDTV', '800x600'.
-->
<!ELEMENT video (present?, colour?, aspect?, quality?)>
<!ELEMENT present (#PCDATA)>
<!ELEMENT colour (#PCDATA)>
<!ELEMENT aspect (#PCDATA)>
<!ELEMENT quality (#PCDATA)>
<!-- Audio details, similar to video details above.
present: whether this programme has any sound at all, 'yes' or 'no'.
stereo: Description of the stereo-ness of the sound. Legal values
are currently 'mono','stereo','dolby','dolby digital','bilingual'
and 'surround'. 'bilingual' in this case refers to a single audio
stream where the left and right channels contain monophonic audio
in different languages. Other values may be added later.
-->
<!ELEMENT audio (present?, stereo?)>
<!ELEMENT stereo (#PCDATA)>
<!-- When and where the programme was last shown, if known. Normally
in TV listings 'repeat' means 'previously shown on this channel', but
if you don't know what channel the old screening was on (but do know
that it happened) then you can omit the 'channel' attribute.
Similarly you can omit the 'start' attribute if you don't know when
the previous transmission was (though you can of course give just the
year, etc.).
The absence of this element does not say for certain that the
programme is brand new and has never been screened anywhere before.
-->
<!ELEMENT previously-shown EMPTY>
<!ATTLIST previously-shown start CDATA #IMPLIED
channel CDATA #IMPLIED >
<!-- 'Premiere'. Different channels have different meanings for this
word - sometimes it means a film has never before been seen on TV in
that country, but other channels use it to mean 'the first showing of
this film on our channel in the current run'. It might have been
shown before, but now they have paid for another set of showings,
which makes the first in that set count as a premiere!
So this element doesn't have a clear meaning, just use it to represent
where 'premiere' would appear in a printed TV listing. You can use
the content of the element to explain exactly what is meant, for
example:
<premiere lang="en">
First showing on national terrestrial TV
</premiere>
The textual content is a 'paragraph' as for <desc>. If you don't want
to give an explanation, just write empty content:
<premiere />
-->
<!ELEMENT premiere (#PCDATA)>
<!ATTLIST premiere lang CDATA #IMPLIED>
<!-- Last-chance. In a way this is the opposite of premiere. Some
channels buy the rights to show a movie a certain number of times, and
the first may be flagged 'premiere', the last as 'last showing'.
For symmetry with premiere, you may use the element content to give a
'paragraph' describing exactly what is meant - it's unlikely to be the
last showing ever! Otherwise, explicitly put empty content:
<last-chance />
-->
<!ELEMENT last-chance (#PCDATA)>
<!ATTLIST last-chance lang CDATA #IMPLIED>
<!-- New. This is the first screened programme from a new show that
has never been shown on television before - if not worldwide then at
least never before in this country. After the first episode or
programme has been shown, subsequent ones are no longer 'new'.
Similarly the second series of an established programme is not 'new'.
Note that this does not mean 'new season' or 'new episode' of an
existing show. You can express part of that using the episode-num
stuff.
-->
<!ELEMENT new EMPTY>
<!-- Subtitles. These can be either 'teletext' (sent digitally, and
displayed at the viewer's request), 'onscreen' (superimposed on the
picture and impossible to get rid of), or 'deaf-signed' (in-vision
signing for users of sign language). You can have multiple subtitle
streams to handle different languages. Language for subtitles is
specified in the same way as for programmes.
-->
<!ELEMENT subtitles (language?)>
<!ATTLIST subtitles type (teletext | onscreen | deaf-signed) #IMPLIED>
<!-- Rating. Various bodies decide on classifications for films -
usually a minimum age you must be to see it. In principle the same
could be done for ordinary TV programmes. Because there are many
systems for doing this, you can also specify the rating system used
(which in practice is the same as the body which made the rating).
-->
<!ELEMENT rating (value, icon*)>
<!ATTLIST rating system CDATA #IMPLIED>
<!-- 'Star rating' - many listings guides award a programme a score as
a quick guide to how good it is. The value of this element should be
'N / M', for example one star out of a possible five stars would be
'1 / 5'. Zero stars is also a possible score (and not the same as
'unrated'). You should try to map whatever wacky system your listings
source uses to a number of stars: so for example if they have thumbs
up, thumbs sideways and thumbs down, you could map that to two, one or
zero stars out of two. If a programme is marked as recommended in a
listings guide you could map this to '1 / 1'. Because there could be many
ways to provide star-ratings or recommendations for a programme, you can
specify multiple star-ratings. You can specify the star-rating system
used, or the provider of the recommendation, with the system attribute.
Whitespace between the numbers and slash is ignored.
-->
<!ELEMENT star-rating (value, icon*)>
<!ATTLIST star-rating system CDATA #IMPLIED>
<!-- Review. Listings guides may provide reviews of programmes in
addition to, or in place of, standard programme descriptions. They are
usually written by in-house reviewers, but reviews can also be made
available by third-party organisations/individuals. The value of this
element must be either the text of the review, or a URL that links to it.
Optional attributes giving the review source and the individual reviewer
can also be specified.
-->
<!ELEMENT review (#PCDATA)>
<!ATTLIST review type (text | url) #REQUIRED
source CDATA #IMPLIED
reviewer CDATA #IMPLIED>
<!-- (Why are things like 'stereo', which must be one of a small
number of values, stored as the contents of elements rather than as
attributes? Because they are data rather than metadata. Attributes
are used for things like the language or encoding of element contents,
or for programme transmission details.) -->

View File

@@ -0,0 +1,130 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema attributeFormDefault="unqualified" elementFormDefault="qualified" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="tv">
<xs:complexType>
<xs:sequence>
<xs:element maxOccurs="unbounded" name="channel">
<xs:complexType>
<xs:sequence>
<xs:element name="display-name">
<xs:complexType>
<xs:simpleContent>
<xs:extension base="xs:string">
<xs:attribute name="lang" type="xs:string" use="required" />
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
<xs:element name="icon">
<xs:complexType>
<xs:attribute name="src" type="xs:string" use="required" />
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required" />
</xs:complexType>
</xs:element>
<xs:element maxOccurs="unbounded" name="programme">
<xs:complexType>
<xs:sequence>
<xs:element name="title">
<xs:complexType>
<xs:simpleContent>
<xs:extension base="xs:string">
<xs:attribute name="lang" type="xs:string" use="required" />
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
<xs:element name="sub-title">
<xs:complexType>
<xs:simpleContent>
<xs:extension base="xs:string">
<xs:attribute name="lang" type="xs:string" use="optional" />
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
<xs:element name="desc">
<xs:complexType>
<xs:simpleContent>
<xs:extension base="xs:string">
<xs:attribute name="lang" type="xs:string" use="required" />
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
<xs:element name="credits">
<xs:complexType>
<xs:sequence>
<xs:element minOccurs="0" maxOccurs="unbounded" name="actor" type="xs:string" />
<xs:element minOccurs="0" name="director" type="xs:string" />
<xs:element name="writer" type="xs:string" />
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="date" type="xs:unsignedInt" />
<xs:element maxOccurs="unbounded" name="category">
<xs:complexType>
<xs:simpleContent>
<xs:extension base="xs:string">
<xs:attribute name="lang" type="xs:string" use="required" />
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
<xs:element name="length">
<xs:complexType>
<xs:simpleContent>
<xs:extension base="xs:unsignedShort">
<xs:attribute name="units" type="xs:string" use="required" />
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
<xs:element name="icon">
<xs:complexType>
<xs:attribute name="src" type="xs:string" use="required" />
</xs:complexType>
</xs:element>
<xs:element minOccurs="0" maxOccurs="unbounded" name="episode-num">
<xs:complexType>
<xs:simpleContent>
<xs:extension base="xs:string">
<xs:attribute name="system" type="xs:string" use="optional" />
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
<xs:element minOccurs="0" name="new" />
<xs:element name="rating">
<xs:complexType>
<xs:sequence>
<xs:element name="value" type="xs:string" />
</xs:sequence>
<xs:attribute name="system" type="xs:string" use="optional" />
</xs:complexType>
</xs:element>
<xs:element name="star-rating">
<xs:complexType>
<xs:sequence>
<xs:element name="value" type="xs:string" />
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="start" type="xs:unsignedLong" use="required" />
<xs:attribute name="channel" type="xs:string" use="required" />
<xs:attribute name="catchup-id" type="xs:string" use="required" />
<xs:attribute name="stop" type="xs:unsignedLong" use="required" />
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="date" type="xs:unsignedLong" use="required" />
<xs:attribute name="source-info-url" type="xs:string" use="required" />
<xs:attribute name="source-info-name" type="xs:string" use="required" />
<xs:attribute name="generator-info-url" type="xs:string" use="required" />
<xs:attribute name="generator-info-name" type="xs:string" use="required" />
</xs:complexType>
</xs:element>
</xs:schema>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

View File

@@ -0,0 +1,204 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from library import Library
from channels import Channels
class Autotune:
def __init__(self, sysARG=sys.argv):
self.log('__init__, sysARG = %s'%(sysARG))
self.sysARG = sysARG
self.channels = Channels()
self.library = Library()
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def getCustom(self) -> dict:
#return autotuned channels ie. channels > CHANNEL_LIMIT
channels = self.channels.getCustom()
self.log('getCustom, channels = %s'%(len(channels)))
return channels
def getAutotuned(self) -> dict:
#return autotuned channels ie. channels > CHANNEL_LIMIT
channels = self.channels.getAutotuned()
self.log('getAutotuned, channels = %s'%(len(channels)))
return channels
def _runTune(self, prompt: bool=False, rebuild: bool=False, dia=None):
customChannels = self.getCustom()
autoChannels = self.getAutotuned()
hasLibrary = PROPERTIES.hasLibrary()
if len(autoChannels) > 0 or hasLibrary: rebuild = PROPERTIES.setEXTPropertyBool('%s.has.Predefined'%(ADDON_ID),True) #rebuild existing autotune, no prompt needed, refresh paths and logos
if len(customChannels) == 0: prompt = True #begin check if prompt or recovery is needed
self.log('_runTune, customChannels = %s, autoChannels = %s'%(len(customChannels),len(autoChannels)))
if prompt:
opt = ''
msg = '%s?'%(LANGUAGE(32042)%(ADDON_NAME))
hasBackup = PROPERTIES.hasBackup()
hasServers = PROPERTIES.hasServers()
hasM3U = FileAccess.exists(M3UFLEPATH) if not hasLibrary else False
if (hasBackup or hasServers or hasM3U):
opt = LANGUAGE(32254)
msg = '%s\n%s'%(LANGUAGE(32042)%(ADDON_NAME),LANGUAGE(32255))
retval = DIALOG.yesnoDialog(message=msg,customlabel=opt)
if retval == 1: dia = DIALOG.progressBGDialog(header='%s, %s'%(ADDON_NAME,LANGUAGE(32021))) #Yes
elif retval == 2: #Custom
with BUILTIN.busy_dialog(), PROPERTIES.interruptActivity():
menu = [LISTITEMS.buildMenuListItem(LANGUAGE(30107),LANGUAGE(33310),url='special://home/addons/%s/resources/lib/utilities.py, Channel_Manager'%(ADDON_ID))]
if hasM3U: menu.append(LISTITEMS.buildMenuListItem(LANGUAGE(32257),LANGUAGE(32256),url='special://home/addons/%s/resources/lib/autotune.py, Recover_M3U'%(ADDON_ID)))
if hasBackup: menu.append(LISTITEMS.buildMenuListItem(LANGUAGE(32112),LANGUAGE(32111),url='special://home/addons/%s/resources/lib/backup.py, Recover_Backup'%(ADDON_ID)))
if hasServers: menu.append(LISTITEMS.buildMenuListItem(LANGUAGE(30173),LANGUAGE(32215),url='special://home/addons/%s/resources/lib/multiroom.py, Select_Server'%(ADDON_ID)))
select = DIALOG.selectDialog(menu,multi=False)
if not select is None: return BUILTIN.executescript(menu[select].getPath())
else: return True #No
else: return True
for idx, ATtype in enumerate(AUTOTUNE_TYPES):
if dia: dia = DIALOG.progressBGDialog(int((idx+1)*100//len(AUTOTUNE_TYPES)),dia,ATtype,'%s, %s'%(ADDON_NAME,LANGUAGE(32021)))
self.selectAUTOTUNE(ATtype, autoSelect=prompt, rebuildChannels=rebuild)
return True
def selectAUTOTUNE(self, ATtype: str, autoSelect: bool=False, rebuildChannels: bool=False):
self.log('selectAUTOTUNE, ATtype = %s, autoSelect = %s, rebuildChannels = %s'%(ATtype,autoSelect,rebuildChannels))
def __buildMenuItem(item):
return LISTITEMS.buildMenuListItem(item['name'],item['type'],item['logo'])
def _match(enabledItems):
for item in enabledItems:
for idx, liz in enumerate(lizlst):
if item.get('name','').lower() == liz.getLabel().lower():
yield idx
def _set(ATtype, selects=[]):
for item in items:
item['enabled'] = False #disable everything before selecting new items.
for select in selects:
if item.get('name','').lower() == lizlst[select].getLabel().lower():
item['enabled'] = True
self.library.setLibrary(ATtype, items)
items = self.library.getLibrary(ATtype)
if len(items) == 0 and (not rebuildChannels and not autoSelect):
if SETTINGS.getSettingBool('Debug_Enable'): DIALOG.notificationDialog(LANGUAGE(32018)%(ATtype))
return
lizlst = poolit(__buildMenuItem)(items)
if rebuildChannels:#rebuild channels.json entries
selects = list(_match(self.library.getEnabled(ATtype)))
elif autoSelect:#build sample channels
if len(items) >= AUTOTUNE_LIMIT:
selects = sorted(set(random.sample(list(set(range(0,len(items)))),AUTOTUNE_LIMIT)))
else:
selects = list(range(0,len(items)))
else:
selects = DIALOG.selectDialog(lizlst,LANGUAGE(32017)%(ATtype),preselect=list(_match(self.library.getEnabled(ATtype))))
if not selects is None: _set(ATtype, selects)
return self.buildAUTOTUNE(ATtype, self.library.getEnabled(ATtype))
def buildAUTOTUNE(self, ATtype: str, items: list=[]):
if not list: return
def buildAvailableRange(existing):
# create number array for given type, excluding existing channel numbers.
if existing: existingNUMBERS = [eitem.get('number') for eitem in existing if eitem.get('number',0) > 0] # existing channel numbers
else: existingNUMBERS = []
start = ((CHANNEL_LIMIT+1)*(AUTOTUNE_TYPES.index(ATtype)+1))
stop = (start + CHANNEL_LIMIT)
self.log('buildAUTOTUNE, ATtype = %s, range = %s-%s, existingNUMBERS = %s'%(ATtype,start,stop,existingNUMBERS))
return [num for num in range(start,stop) if num not in existingNUMBERS]
existingAUTOTUNE = self.channels.popChannels(ATtype,self.getAutotuned())
usesableNUMBERS = iter(buildAvailableRange(existingAUTOTUNE)) # available channel numbers
for item in items:
music = isRadio(item)
citem = self.channels.getTemplate()
citem.update({"id" : "",
"type" : ATtype,
"number" : 0,
"name" : getChannelSuffix(item['name'], ATtype),
"logo" : item.get('logo',LOGO),
"path" : item.get('path',''),
"group" : [item.get('type','')],
"rules" : item.get('rules',{}),
"catchup" : ('vod' if not music else ''),
"radio" : music,
"favorite": True})
match, eitem = self.channels.findAutotuned(citem, channels=existingAUTOTUNE)
if match is None: #new autotune
citem['id'] = getChannelID(citem['name'],citem['path'],citem['number']) #generate new channelid
citem['number'] = next(usesableNUMBERS,0) #first available channel number
PROPERTIES.setUpdateChannels(citem['id'])
else: #update existing autotune
citem['id'] = eitem.get('id')
citem['number'] = eitem.get('number')
citem['logo'] = chkLogo(eitem.get('logo',''),citem.get('logo',LOGO))
citem['favorite'] = eitem.get('favorite',False)
self.log('[%s] buildAUTOTUNE, number = %s, match = %s'%(citem['id'],citem['number'],match))
self.channels.addChannel(citem)
return self.channels.setChannels()
def recoverM3U(self, autotune={}):
from m3u import M3U
stations = M3U().getStations()
[autotune.setdefault(AUTOTUNE_TYPES[station.get('number')//1000],[]).append(station.get('name')) for station in stations if station.get('number') > CHANNEL_LIMIT]
[self.library.enableByName(type, names) for type, names in list(autotune.items()) if len(names) > 0]
return BUILTIN.executescript('special://home/addons/%s/resources/lib/utilities.py, Run_Autotune'%(ADDON_ID))
def clearLibrary(self):
self.library.resetLibrary()
DIALOG.notificationDialog(LANGUAGE(32025))
def clearBlacklist(self):
SETTINGS.setSetting('Clear_BlackList','')
DIALOG.notificationDialog(LANGUAGE(32025))
def run(self):
with BUILTIN.busy_dialog():
ctl = (1,1) #settings return focus
try: param = self.sysARG[1]
except: param = None
if param.replace('_',' ') in AUTOTUNE_TYPES:
ctl = (1,AUTOTUNE_TYPES.index(param.replace('_',' '))+1)
self.selectAUTOTUNE(param.replace('_',' '))
elif param == 'Clear_Autotune' : self.clearLibrary()
elif param == 'Clear_BlackList': self.clearBlacklist()
elif param == 'Recover_M3U': self.recoverM3U()
elif param == None: return
return SETTINGS.openSettings(ctl)
if __name__ == '__main__': timerit(Autotune(sys.argv).run)(0.1)

View File

@@ -0,0 +1,98 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# https://github.com/kodi-community-addons/script.module.simplecache/blob/master/README.md
# -*- coding: utf-8 -*-
from globals import *
from library import Library
from channels import Channels
class Backup:
def __init__(self, sysARG=sys.argv):
self.sysARG = sysARG
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def getFileDate(self, file: str) -> str:
try: return datetime.datetime.fromtimestamp(pathlib.Path(FileAccess.translatePath(file)).stat().st_mtime).strftime(BACKUP_TIME_FORMAT)
except: return LANGUAGE(32105) #Unknown
def hasBackup(self, file: str=CHANNELFLE_BACKUP) -> bool:
self.log('hasBackup')
if PROPERTIES.setBackup(FileAccess.exists(file)):
if file == CHANNELFLE_BACKUP:#main backup file, set meta.
if (SETTINGS.getSetting('Backup_Channels') or 'Last Backup: Unknown') == 'Last Backup: Unknown':
SETTINGS.setSetting('Backup_Channels' ,'%s: %s'%(LANGUAGE(32106),self.getFileDate(file)))
if not SETTINGS.getSetting('Recover_Backup'):
SETTINGS.setSetting('Recover_Backup','%s [B]%s[/B] Channels?'%(LANGUAGE(32107),len(self.getChannels())))
return True
SETTINGS.setSetting('Backup_Channels' ,'')
SETTINGS.setSetting('Recover_Backup','')
return False
def getChannels(self, file: str=CHANNELFLE_BACKUP) -> list:
self.log('getChannels')
channels = Channels()
citems = channels._load(file).get('channels',[])
del channels
return citems
def backupChannels(self, file: str=CHANNELFLE_BACKUP) -> bool:
self.log('backupChannels')
if FileAccess.exists(file):
if not DIALOG.yesnoDialog('%s\n%s?'%(LANGUAGE(32108),SETTINGS.getSetting('Backup_Channels'))):
return False
with BUILTIN.busy_dialog(), PROPERTIES.interruptActivity():
if FileAccess.copy(CHANNELFLEPATH,file):
if file == CHANNELFLE_BACKUP: #main backup file, set meta.
PROPERTIES.setBackup(True)
SETTINGS.setSetting('Backup_Channels' ,'%s: %s'%(LANGUAGE(32106),datetime.datetime.now().strftime(BACKUP_TIME_FORMAT)))
SETTINGS.setSetting('Recover_Backup','%s [B]%s[/B] Channels?'%(LANGUAGE(32107),len(self.getChannels())))
return DIALOG.notificationDialog('%s %s'%(LANGUAGE(32110),LANGUAGE(32025)))
self.hasBackup()
SETTINGS.openSettings(ctl)
def recoverChannels(self, file: str=CHANNELFLE_BACKUP) -> bool:
self.log('recoverChannels, file = %s'%(file))
if not DIALOG.yesnoDialog('%s'%(LANGUAGE(32109)%(SETTINGS.getSetting('Recover_Backup').replace(LANGUAGE(30216),''),SETTINGS.getSetting('Backup_Channels')))):
return False
with BUILTIN.busy_dialog(), PROPERTIES.interruptActivity():
FileAccess.move(CHANNELFLEPATH,CHANNELFLE_RESTORE)
if FileAccess.copy(file,CHANNELFLEPATH):
Library().resetLibrary()
PROPERTIES.setPendingRestart()
def run(self):
with BUILTIN.busy_dialog():
ctl = (0,1) #settings return focus
try: param = self.sysARG[1]
except: param = None
if param == 'Recover_Backup': self.recoverChannels()
elif param == 'Backup_Channels': self.backupChannels()
if __name__ == '__main__': timerit(Backup(sys.argv).run)(0.1)

View File

@@ -0,0 +1,590 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from channels import Channels
from xmltvs import XMLTVS
from xsp import XSP
from m3u import M3U
from fillers import Fillers
from resources import Resources
from seasonal import Seasonal
from rules import RulesList
class Service:
from jsonrpc import JSONRPC
player = PLAYER()
monitor = MONITOR()
jsonRPC = JSONRPC()
def _interrupt(self) -> bool:
return PROPERTIES.isPendingInterrupt()
def _suspend(self) -> bool:
return PROPERTIES.isPendingSuspend()
class Builder:
loopback = {}
def __init__(self, service=None):
if service is None: service = Service()
self.service = service
self.jsonRPC = service.jsonRPC
self.cache = service.jsonRPC.cache
self.channels = Channels()
#global dialog
self.pDialog = None
self.pCount = 0
self.pMSG = ''
self.pName = ''
self.pErrors = []
#global rules
self.accurateDuration = bool(SETTINGS.getSettingInt('Duration_Type'))
self.enableEven = bool(SETTINGS.getSettingInt('Enable_Even'))
self.interleaveValue = SETTINGS.getSettingInt('Interleave_Value')
self.incStrms = SETTINGS.getSettingBool('Enable_Strms')
self.inc3D = SETTINGS.getSettingBool('Enable_3D')
self.incExtras = SETTINGS.getSettingBool('Enable_Extras')
self.fillBCTs = SETTINGS.getSettingBool('Enable_Fillers')
self.saveDuration = SETTINGS.getSettingBool('Store_Duration')
self.epgArt = SETTINGS.getSettingInt('EPG_Artwork')
self.enableGrouping = SETTINGS.getSettingBool('Enable_Grouping')
self.minDuration = SETTINGS.getSettingInt('Seek_Tolerance')
self.limit = SETTINGS.getSettingInt('Page_Limit')
self.filelistQuota = False
self.schedulingQuota = True
self.filters = {}#{"and": [{"operator": "contains", "field": "title", "value": "Star Wars"},{"operator": "contains", "field": "tag", "value": "Good"}],"or":[]}
self.sort = {}#{"ignorearticle":True,"method":"random","order":"ascending","useartistsortname":True}
self.limits = {"end":-1,"start":0,"total":0}
self.bctTypes = {"ratings" :{"min":-1, "max":SETTINGS.getSettingInt('Enable_Preroll'), "auto":SETTINGS.getSettingInt('Enable_Preroll') == -1, "enabled":bool(SETTINGS.getSettingInt('Enable_Preroll')), "chance":SETTINGS.getSettingInt('Random_Pre_Chance'),
"sources" :{"ids":SETTINGS.getSetting('Resource_Ratings').split('|'),"paths":[os.path.join(FILLER_LOC,'Ratings' ,'')]},"items":{}},
"bumpers" :{"min":-1, "max":SETTINGS.getSettingInt('Enable_Preroll'), "auto":SETTINGS.getSettingInt('Enable_Preroll') == -1, "enabled":bool(SETTINGS.getSettingInt('Enable_Preroll')), "chance":SETTINGS.getSettingInt('Random_Pre_Chance'),
"sources" :{"ids":SETTINGS.getSetting('Resource_Bumpers').split('|'),"paths":[os.path.join(FILLER_LOC,'Bumpers' ,'')]},"items":{}},
"adverts" :{"min":SETTINGS.getSettingInt('Enable_Postroll'), "max":PAGE_LIMIT, "auto":SETTINGS.getSettingInt('Enable_Postroll') == -1, "enabled":bool(SETTINGS.getSettingInt('Enable_Postroll')), "chance":SETTINGS.getSettingInt('Random_Post_Chance'),
"sources" :{"ids":SETTINGS.getSetting('Resource_Adverts').split('|'),"paths":[os.path.join(FILLER_LOC,'Adverts' ,'')]},"items":{}},
"trailers":{"min":SETTINGS.getSettingInt('Enable_Postroll'), "max":PAGE_LIMIT, "auto":SETTINGS.getSettingInt('Enable_Postroll') == -1, "enabled":bool(SETTINGS.getSettingInt('Enable_Postroll')), "chance":SETTINGS.getSettingInt('Random_Post_Chance'),
"sources" :{"ids":SETTINGS.getSetting('Resource_Trailers').split('|'),"paths":[os.path.join(FILLER_LOC,'Trailers','')]},"items":{}, "incKODI":SETTINGS.getSettingBool('Include_Trailers_KODI')}}
self.xsp = XSP()
self.xmltv = XMLTVS()
self.m3u = M3U()
self.resources = Resources(service=self.service)
self.runActions = RulesList(self.channels.getChannels()).runActions
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def updateProgress(self, percent, message, header):
if self.pDialog: self.pDialog = DIALOG.updateProgress(percent, self.pDialog, message=message, header=header)
def getVerifiedChannels(self):
return sorted(self.verify(), key=itemgetter('number'))
def verify(self, channels=[]):
if not channels: channels = self.channels.getChannels()
for idx, citem in enumerate(channels):
if not citem.get('name') or len(citem.get('path',[])) == 0 or not citem.get('number'):
self.log('[%s] SKIPPING - missing necessary channel meta\n%s'%(citem.get('id'),citem))
continue
elif not citem.get('id'): citem['id'] = getChannelID(citem['name'],citem['path'],citem['number']) #generate new channelid
citem['logo'] = self.resources.getLogo(citem,citem.get('logo',LOGO))
self.log('[%s] VERIFIED - channel %s: %s'%(citem['id'],citem['number'],citem['name']))
yield self.runActions(RULES_ACTION_CHANNEL_CITEM, citem, citem, inherited=self) #inject persistent citem changes here
def build(self, channels: list=[], preview=False):
def __hasProgrammes(citem: dict) -> bool:
try: return dict(self.xmltv.hasProgrammes([citem])).get(citem['id'],False)
except: return False
def __hasFileList(fileList: list) -> bool:
if isinstance(fileList,list):
if len(fileList) > 0: return True
return False
def __clrChannel(citem: dict) -> bool:
self.log('[%s] __clrChannel'%(citem['id']))
return self.m3u.delStation(citem) & self.xmltv.delBroadcast(citem)
def __addStation(citem: dict) -> bool:
self.log('[%s] __addStation'%(citem['id']))
citem['logo'] = self.resources.buildWebImage(cleanImage(citem['logo']))
citem['group'] = cleanGroups(citem, self.enableGrouping)
sitem = self.m3u.getStationItem(citem)
return self.m3u.addStation(sitem) & self.xmltv.addChannel(sitem)
def __addProgrammes(citem: dict, fileList: list) -> bool:
self.log('[%s] __addProgrammes, fileList = %s'%(citem['id'],len(fileList)))
for idx, item in enumerate(fileList): self.xmltv.addProgram(citem['id'], self.xmltv.getProgramItem(citem, item))
return True
def __setChannels():
self.log('__setChannels')
return self.xmltv._save() & self.m3u._save()
if not PROPERTIES.isRunning('builder.build'):
with PROPERTIES.legacy(), PROPERTIES.chkRunning('builder.build'):
try:
if len(channels) == 0: raise Exception('No individual channels to update, updating all!')
else: channels = sorted(self.verify(channels), key=itemgetter('number'))
except: channels = self.getVerifiedChannels()
if len(channels) > 0:
complete = True
updated = set()
now = getUTCstamp()
start = roundTimeDown(now,offset=60)#offset time to start bottom of the hour
fallback = datetime.datetime.fromtimestamp(start).strftime(DTFORMAT)
clrIDS = SETTINGS.getResetChannels()
if preview: self.pDialog = DIALOG.progressDialog()
else: self.pDialog = DIALOG.progressBGDialog()
for idx, citem in enumerate(channels):
self.pCount = int(idx*100//len(channels))
citem = self.runActions(RULES_ACTION_CHANNEL_TEMP_CITEM, citem, citem, inherited=self) #inject temporary citem changes here
self.log('[%s] build, preview = %s, rules = %s'%(citem['id'],preview,citem.get('rules',{})))
if self.service._interrupt():
self.log("[%s] build, _interrupt"%(citem['id']))
complete = False
self.pErrors = [LANGUAGE(32160)]
self.updateProgress(self.pCount, message='%s: %s'%(LANGUAGE(32144),LANGUAGE(32213)), header=ADDON_NAME)
break
else:
self.pMSG = '%s: %s'%(LANGUAGE(32144),LANGUAGE(32212))
self.pName = citem['name']
self.runActions(RULES_ACTION_CHANNEL_START, citem, inherited=self)
if not preview and citem['id'] in clrIDS: __clrChannel({'id':clrIDS.pop(clrIDS.index(citem['id']))}) #clear channel xmltv
stopTimes = dict(self.xmltv.loadStopTimes([citem], fallback=fallback)) #check last stop times
if preview: self.pMSG = LANGUAGE(32236) #Preview
elif (stopTimes.get(citem['id']) or start) > (now + ((MAX_GUIDEDAYS * 86400) - 43200)): self.pMSG = '%s %s'%(LANGUAGE(32028),LANGUAGE(32023)) #Checking
elif (stopTimes.get(citem['id']) or fallback) == fallback: self.pMSG = '%s %s'%(LANGUAGE(30014),LANGUAGE(32023)) #Building
elif stopTimes.get(citem['id']): self.pMSG = '%s %s'%(LANGUAGE(32022),LANGUAGE(32023)) #Updating
else: self.pMSG = '%s %s'%(LANGUAGE(32245),LANGUAGE(32023)) #Parsing
self.updateProgress(self.pCount, message='%s: %s'%(LANGUAGE(32248),self.pName), header='%s, %s'%(ADDON_NAME,self.pMSG))
response = self.getFileList(citem, now, (stopTimes.get(citem['id']) or start))# {False:'In-Valid Channel', True:'Valid Channel w/o programmes', list:'Valid Channel w/ programmes}
if preview: return response
elif response:
if __addStation(citem) and __hasFileList(response): updated.add(__addProgrammes(citem, response)) #added xmltv lineup entries.
else:
if complete: self.pErrors.append(LANGUAGE(32026))
chanErrors = ' | '.join(list(sorted(set(self.pErrors))))
self.log('[%s] build, In-Valid Channel (%s) %s'%(citem['id'],self.pName,chanErrors))
self.updateProgress(self.pCount, message='%s: %s'%(self.pName,chanErrors),header='%s, %s'%(ADDON_NAME,'%s %s'%(LANGUAGE(32027),LANGUAGE(32023))))
if not __hasProgrammes(citem):
self.updateProgress(self.pCount, message=self.pName,header='%s, %s'%(ADDON_NAME,'%s %s'%(LANGUAGE(32244),LANGUAGE(32023))))
__clrChannel(citem) #remove m3u/xmltv references when no valid programmes found. # todo del citem causes issues down the road with citem missing params. reeval need to remove here
self.runActions(RULES_ACTION_CHANNEL_STOP, citem, inherited=self)
SETTINGS.setResetChannels(clrIDS)
self.pDialog = DIALOG.updateProgress(100, self.pDialog, message='%s %s'%(self.pMSG,LANGUAGE(32025) if complete else LANGUAGE(32135)))
self.log('build, complete = %s, updated = %s, saved = %s'%(complete,bool(updated),__setChannels()))
return complete, bool(updated)
else: self.log('build, no verified channels found!')
return False, False
def getFileList(self, citem: dict, now: time, start: time) -> bool and list:
self.log('[%s] getFileList, start = %s'%(citem['id'],start))
try:
if start > (now + ((MAX_GUIDEDAYS * 86400) - 43200)): #max guidedata days to seconds, minus fill buffer (12hrs) in seconds.
self.updateProgress(self.pCount, message=self.pName, header='%s, %s'%(ADDON_NAME,self.pMSG))
self.log('[%s] getFileList, programmes over MAX_DAYS! start = %s'%(citem['id'],datetime.datetime.fromtimestamp(start)),xbmc.LOGINFO)
return True# prevent over-building
multi = len(citem.get('path',[])) > 1 #multi-path source
radio = True if citem.get('radio',False) else False
media = 'music' if radio else 'video'
self.log('[%s] getFileList, multipath = %s, radio = %s, media = %s, path = %s'%(citem['id'],multi,radio,media,citem.get('path')),xbmc.LOGINFO)
if radio: response = self.buildRadio(citem)
else: response = self.buildChannel(citem)
if isinstance(response,list): return sorted(self.addScheduling(citem, response, now, start), key=itemgetter('start'))
elif self.service._interrupt():
self.log("[%s] getFileList, _interrupt"%(citem['id']))
self.updateProgress(self.pCount, message='%s: %s'%(LANGUAGE(32144),LANGUAGE(32213)), header=ADDON_NAME)
return True
else:
return response
except Exception as e: self.log("[%s] getFileList, failed! %s"%(citem['id'],e), xbmc.LOGERROR)
return False
def buildCells(self, citem: dict={}, duration: int=10800, type: str='video', entries: int=3, info: dict={}) -> list:
tmpItem = {'label' : (info.get('title') or citem['name']),
'episodetitle': (info.get('episodetitle') or '|'.join(citem['group'])),
'plot' : (info.get('plot') or LANGUAGE(32020)),
'genre' : (info.get('genre') or ['Undefined']),
'file' : (info.get('path') or info.get('file') or info.get('originalpath') or '|'.join(citem.get('path'))),
'art' : (info.get('art') or {"thumb":COLOR_LOGO,"fanart":FANART,"logo":LOGO,"icon":LOGO}),
'type' : type,
'duration' : duration,
'start' : 0,
'stop' : 0}
info.update(tmpItem)
return [info.copy() for idx in range(entries)]
def addScheduling(self, citem: dict, fileList: list, now: time, start: time) -> list: #quota meet MIN_EPG_DURATION requirements.
self.log("[%s] addScheduling, IN fileList = %s, now = %s, start = %s"%(citem['id'],len(fileList),now,start))
totDur = 0
tmpList = []
fileList = self.runActions(RULES_ACTION_CHANNEL_BUILD_TIME_PRE, citem, fileList, inherited=self)
for idx, item in enumerate(fileList):
item["idx"] = idx
item['start'] = start
item['stop'] = start + item['duration']
start = item['stop']
tmpList.append(item)
if len(tmpList) > 0:
iters = cycle(fileList)
while not self.service.monitor.abortRequested() and tmpList[-1].get('stop') <= (now + MIN_EPG_DURATION):
if self.service.monitor.waitForAbort(0.0001): break
elif tmpList[-1].get('stop') >= (now + MIN_EPG_DURATION):
self.log("[%s] addScheduling, OUT fileList = %s, stop = %s"%(citem['id'],len(tmpList),tmpList[-1].get('stop')))
break
else:
idx += 1
item = next(iters).copy()
item["idx"] = idx
item['start'] = start
item['stop'] = start + item['duration']
start = item['stop']
totDur += item['duration']
tmpList.append(item)
self.updateProgress(self.pCount, message="%s: %s %s/%s"%(self.pName,LANGUAGE(33085),totDur,MIN_EPG_DURATION),header='%s, %s'%(ADDON_NAME,self.pMSG))
self.log("[%s] addScheduling, ADD fileList = %s, totDur = %s/%s, stop = %s"%(citem['id'],len(tmpList),totDur,MIN_EPG_DURATION,tmpList[-1].get('stop')))
return self.runActions(RULES_ACTION_CHANNEL_BUILD_TIME_POST, citem, tmpList, inherited=self) #adv. scheduling second pass and cleanup.
def buildRadio(self, citem: dict) -> list:
self.log("[%s] buildRadio"%(citem['id']))
#todo insert custom radio labels,plots based on genre type?
# https://www.musicgenreslist.com/
# https://www.musicgateway.com/blog/how-to/what-are-the-different-genres-of-music
return self.buildCells(citem, MIN_EPG_DURATION, 'music', ((MAX_GUIDEDAYS * 8)), info={'genre':["Music"],'art':{'thumb':citem['logo'],'icon':citem['logo'],'fanart':citem['logo']},'plot':LANGUAGE(32029)%(citem['name'])})
def buildChannel(self, citem: dict) -> bool and list:
def _validFileList(fileArray):
for fileList in fileArray:
if len(fileList) > 0: return True
def _injectFillers(citem, fileList, enable=False):
self.log("[%s] buildChannel: _injectFillers, fileList = %s, enable = %s"%(citem['id'],len(fileList),enable))
if enable: return Fillers(self,citem).injectBCTs(fileList)
else: return fileList
def _injectRules(citem):
def __chkEvenDistro(citem):
if self.enableEven and not citem.get('rules',{}).get("1000"):
nrules = {"1000":{"values":{"0":SETTINGS.getSettingInt('Enable_Even'),"1":SETTINGS.getSettingInt('Page_Limit'),"2":SETTINGS.getSettingBool('Enable_Force_Episode')}}}
self.log(" [%s] buildChannel: _injectRules, __chkEvenDistro, new rules = %s"%(citem['id'],nrules))
citem.setdefault('rules',{}).update(nrules)
return citem
return __chkEvenDistro(citem)
citem = _injectRules(citem) #inject temporary adv. channel rules here
fileArray = self.runActions(RULES_ACTION_CHANNEL_BUILD_FILEARRAY_PRE, citem, list(), inherited=self) #inject fileArray thru adv. channel rules here
self.log("[%s] buildChannel, channel pre fileArray items = %s"%(citem['id'],len(fileArray)),xbmc.LOGINFO)
#Primary rule for handling fileList injection bypassing channel building below.
if not _validFileList(fileArray): #if valid array bypass channel building
for idx, file in enumerate(citem.get('path',[])):
if self.service._interrupt():
self.log("[%s] buildChannel, _interrupt"%(citem['id']))
self.updateProgress(self.pCount, message='%s: %s'%(LANGUAGE(32144),LANGUAGE(32213)), header=ADDON_NAME)
return []
else:
if len(citem.get('path',[])) > 1: self.pName = '%s %s/%s'%(citem['name'],idx+1,len(citem.get('path',[])))
fileList = self.buildFileList(citem, self.runActions(RULES_ACTION_CHANNEL_BUILD_PATH, citem, file, inherited=self), 'video', self.limit, self.sort, self.limits)
fileArray.append(fileList)
self.log("[%s] buildChannel, path = %s, fileList = %s"%(citem['id'],file,len(fileList)))
fileArray = self.runActions(RULES_ACTION_CHANNEL_BUILD_FILEARRAY_POST, citem, fileArray, inherited=self) #flatten fileArray here to pass as fileList below
#Primary rule for handling adv. interleaving, must return single list to avoid default interleave() below. Add adv. rule to setDictLST duplicates
if isinstance(fileArray, list):
self.log("[%s] buildChannel, channel post fileArray items = %s"%(citem['id'],len(fileArray)),xbmc.LOGINFO)
if not _validFileList(fileArray):#check that at least one fileList in array contains meta
self.log("[%s] buildChannel, channel fileArray In-Valid!"%(citem['id']),xbmc.LOGINFO)
return False
self.log("[%s] buildChannel, fileArray = %s"%(citem['id'],','.join(['[%s]'%(len(fileList)) for fileList in fileArray])))
fileList = self.runActions(RULES_ACTION_CHANNEL_BUILD_FILELIST_PRE, citem, interleave(fileArray, self.interleaveValue), inherited=self)
self.log('[%s] buildChannel, pre fileList items = %s'%(citem['id'],len(fileList)),xbmc.LOGINFO)
fileList = self.runActions(RULES_ACTION_CHANNEL_BUILD_FILELIST_POST, citem, _injectFillers(citem, fileList, self.fillBCTs), inherited=self)
self.log('[%s] buildChannel, post fileList items = %s'%(citem['id'],len(fileList)),xbmc.LOGINFO)
else: fileList = fileArray
return self.runActions(RULES_ACTION_CHANNEL_BUILD_FILELIST_RETURN, citem, fileList, inherited=self)
def buildFileList(self, citem: dict, path: str, media: str='video', page: int=SETTINGS.getSettingInt('Page_Limit'), sort: dict={}, limits: dict={}) -> list: #build channel via vfs path.
self.log("[%s] buildFileList, media = %s, path = %s\nlimit = %s, sort = %s, page = %s"%(citem['id'],media,path,page,sort,limits))
self.loopback = {}
def __padFileList(fileItems, page):
if page > len(fileItems):
tmpList = fileItems * (page // len(fileItems))
tmpList.extend(fileItems[:page % len(fileItems)])
return tmpList
return fileItems
fileArray = []
if path.endswith('.xsp'): #smartplaylist - parse xsp for path, sort info
paths, media, sort, page = self.xsp.parseXSP(citem.get('id',''), path, media, sort, page)
if len(paths) > 0:
for idx, npath in enumerate(paths):
self.pName = '%s %s/%s'%(citem['name'],idx+1,len(paths))
fileArray.append(self.buildFileList(citem, npath, media, page, sort, limits))
return interleave(fileArray, self.interleaveValue)
elif 'db://' in path and '?xsp=' in path: #dynamicplaylist - parse xsp for path, filter and sort info
path, media, sort, filter = self.xsp.parseDXSP(citem.get('id',''), path, sort, {}, self.incExtras) #todo filter adv. rules
fileList = []
dirList = [{'file':path}]
npath = path
nlimits = limits
self.log("[%s] buildFileList, page = %s, sort = %s, limits = %s\npath = %s"%(citem['id'],page,sort,limits,path))
while not self.service.monitor.abortRequested() and len(fileList) < page:
#Not all results are flat hierarchies; walk all paths until fileList page is reached. ie. folders with pagination and/or directories
if self.service._interrupt():
self.log("[%s] buildFileList, _interrupt"%(citem['id']))
self.updateProgress(self.pCount, message='%s: %s'%(LANGUAGE(32144),LANGUAGE(32213)), header=ADDON_NAME)
return []
elif self.service._suspend():
self.log("[%s] buildFileList, _suspend"%(citem['id']))
self.updateProgress(self.pCount, message='%s: %s'%(LANGUAGE(32144),LANGUAGE(32145)), header=ADDON_NAME)
self.service.monitor.waitForAbort(SUSPEND_TIMER)
continue
elif len(dirList) > 0:
dir = dirList.pop(0)
npath = dir.get('file')
subfileList, subdirList, nlimits, errors = self.buildList(citem, npath, media, abs(page - len(fileList)), sort, limits, dir) #parse all directories under root. Flattened hierarchies required to stream line channel building.
fileList += subfileList
dirList = setDictLST(dirList + subdirList)
self.log('[%s] buildFileList, adding = %s/%s remaining dirs (%s)\npath = %s, limits = %s'%(citem['id'],len(fileList),page,len(dirList),npath,nlimits))
elif len(dirList) == 0:
if len(fileList) > 0 and nlimits.get('total',0) > 0:
dirList.insert(0,{'file':npath})
self.log('[%s] buildFileList, reparse path %s'%(citem['id'],npath))
else:
self.log('[%s] buildFileList, no more folders to parse'%(citem['id']))
break
self.log("[%s] buildFileList, returning fileList %s/%s"%(citem['id'],len(fileList),page))
return fileList
def buildList(self, citem: dict, path: str, media: str='video', page: int=SETTINGS.getSettingInt('Page_Limit'), sort: dict={}, limits: dict={}, dirItem: dict={}, query: dict={}):
self.log("[%s] buildList, media = %s, path = %s\npage = %s, sort = %s, query = %s, limits = %s\ndirItem = %s"%(citem['id'],media,path,page,sort,query,limits,dirItem))
dirList, fileList, seasoneplist, trailersdict = [], [], [], {}
items, nlimits, errors = self.jsonRPC.requestList(citem, path, media, page, sort, limits, query)
if errors.get('message'):
self.pErrors.append(errors['message'])
return fileList, dirList, nlimits, errors
elif items == self.loopback and limits != nlimits:# malformed jsonrpc queries will return root response, catch a re-parse and return.
self.log("[%s] buildList, loopback detected using path = %s\nreturning: fileList (%s), dirList (%s)"%(citem['id'],path,len(fileList),len(dirList)))
self.pErrors.append(LANGUAGE(32030))
return fileList, dirList, nlimits, errors
elif not items and len(fileList) == 0:
self.log("[%s] buildList, no request items found using path = %s\nreturning: fileList (%s), dirList (%s)"%(citem['id'],path,len(fileList),len(dirList)))
self.pErrors.append(LANGUAGE(32026))
return fileList, dirList, nlimits, errors
elif items:
self.loopback = items
for idx, item in enumerate(items):
file = item.get('file','')
fileType = item.get('filetype','file')
if not item.get('type'): item['type'] = query.get('key','files')
if self.service._interrupt():
self.log("[%s] buildList, _interrupt"%(citem['id']))
self.updateProgress(self.pCount, message='%s: %s'%(LANGUAGE(32144),LANGUAGE(32213)), header=ADDON_NAME)
self.jsonRPC.autoPagination(citem['id'], path, query, limits) #rollback pagination limits
return [], [], nlimits, errors
elif fileType == 'directory':
dirList.append(item)
# self.updateProgress(self.pCount, message=f'{self.pName}: {int(idx*100)//page}% appending: {item.get("label")}',header=f'{ADDON_NAME}, {self.pMSG}')
self.log("[%s] buildList, IDX = %s, appending directory: %s"%(citem['id'],idx,file),xbmc.LOGINFO)
elif fileType == 'file':
if file.startswith('pvr://'): #parse encoded fileitem otherwise no relevant meta provided via org. query. playable pvr:// paths are limited in Kodi.
self.log("[%s] buildList, IDX = %s, PVR item => FileItem! file = %s"%(citem['id'],idx,file),xbmc.LOGINFO)
item = decodePlot(item.get('plot',''))
file = item.get('file')
if not file:
self.pErrors.append(LANGUAGE(32031))
self.log("[%s] buildList, IDX = %s, skipping missing playable file! path = %s"%(citem['id'],idx,path),xbmc.LOGINFO)
continue
elif (file.lower().endswith('strm') and not self.incStrms):
self.pErrors.append('%s STRM'%(LANGUAGE(32027)))
self.log("[%s] buildList, IDX = %s, skipping strm file! file = %s"%(citem['id'],idx,file),xbmc.LOGINFO)
continue
if not item.get('streamdetails',{}).get('video',[]) and not file.startswith(tuple(VFS_TYPES)): #parsing missing meta, kodi rpc bug fails to return streamdetails during Files.GetDirectory.
item['streamdetails'] = self.jsonRPC.getStreamDetails(file, media)
if (self.is3D(item) and not self.inc3D):
self.pErrors.append('%s 3D'%(LANGUAGE(32027)))
self.log("[%s] buildList, IDX = %s skipping 3D file! file = %s"%(citem['id'],idx,file),xbmc.LOGINFO)
continue
title = (item.get("title") or item.get("label") or dirItem.get('label') or '')
tvtitle = (item.get("showtitle") or item.get("label") or dirItem.get('label') or '')
if (item['type'].startswith(tuple(TV_TYPES)) or item.get("showtitle")):# This is a TV show
season = int(item.get("season","0"))
episode = int(item.get("episode","0"))
if not file.startswith(tuple(VFS_TYPES)) and not self.incExtras and (season == 0 or episode == 0):
self.pErrors.append('%s Extras'%(LANGUAGE(32027)))
self.log("[%s] buildList, IDX = %s skipping extras! file = %s"%(citem['id'],idx,file),xbmc.LOGINFO)
continue
label = tvtitle
item["tvshowtitle"] = tvtitle
item["episodetitle"] = title
item["episodelabel"] = '%s%s'%(title,' (%sx%s)'%(season,str(episode).zfill(2))) #Episode Title (SSxEE) Mimic Kodi's PVR label format
item["showlabel"] = '%s%s'%(item["tvshowtitle"],' - %s'%(item['episodelabel']) if item['episodelabel'] else '')
else: # This is a Movie
label = title
item["episodetitle"] = item.get("tagline","")
item["episodelabel"] = item.get("tagline","")
item["showlabel"] = '%s%s'%(item["title"], ' - %s'%(item['episodelabel']) if item['episodelabel'] else '')
if not label:
self.pErrors.append(LANGUAGE(32018)(LANGUAGE(30188)))
continue
dur = self.jsonRPC.getDuration(file, item, self.accurateDuration, self.saveDuration)
if dur > self.minDuration: #include media that's duration is above the players seek tolerance & users adv. rule
self.updateProgress(self.pCount, message='%s: %s'%(self.pName,int(idx*100)//page)+'%',header='%s, %s'%(ADDON_NAME,self.pMSG))
item['duration'] = dur
item['media'] = media
item['originalpath'] = path #use for path sorting/playback verification
item['friendly'] = SETTINGS.getFriendlyName()
item['remote'] = PROPERTIES.getRemoteHost()
if item.get("year",0) == 1601: item['year'] = 0 #detect kodi bug that sets a fallback year to 1601 https://github.com/xbmc/xbmc/issues/15554
spTitle, spYear = splitYear(label)
item['label'] = spTitle
if item.get('year',0) == 0 and spYear: item['year'] = spYear #replace missing item year with one parsed from show title
item['plot'] = (item.get("plot","") or item.get("plotoutline","") or item.get("description","") or LANGUAGE(32020)).strip()
if query.get('holiday'):
citem['holiday'] = query.get('holiday')
holiday = "[B]%s[/B] - [I]%s[/I]"%(query["holiday"]["name"],query["holiday"]["tagline"]) if query["holiday"]["tagline"] else "[B]%s[/B]"%(query["holiday"]["name"])
item["plot"] = "%s \n%s"%(holiday,item["plot"])
item['art'] = (item.get('art',{}) or dirItem.get('art',{}))
item.get('art',{})['icon'] = citem['logo']
if item.get('trailer') and self.bctTypes['trailers'].get('enabled',False):
titem = item.copy()
tdur = self.jsonRPC.getDuration(titem.get('trailer'), accurate=True, save=False)
if tdur > 0:
titem.update({'label':'%s - %s'%(item["label"],LANGUAGE(30187)),'episodetitle':'%s - %s'%(item["episodetitle"],LANGUAGE(30187)),'episodelabel':'%s - %s'%(item["episodelabel"],LANGUAGE(30187)),'duration':tdur, 'runtime':tdur, 'file':titem['trailer'], 'streamdetails':{}})
[trailersdict.setdefault(genre.lower(),[]).append(titem) for genre in (titem.get('genre',[]) or ['resources'])]
if sort.get("method","") == 'episode' and (int(item.get("season","0")) + int(item.get("episode","0"))) > 0:
seasoneplist.append([int(item.get("season","0")), int(item.get("episode","0")), item])
else:
fileList.append(item)
else:
self.pErrors.append(LANGUAGE(32032))
self.log("[%s] buildList, IDX = %s skipping content no duration meta found! or runtime below minDuration (%s/%s) file = %s"%(citem['id'],idx,dur,self.minDuration,file),xbmc.LOGINFO)
if sort.get("method","").startswith('episode'):
self.log("[%s] buildList, sorting by episode"%(citem['id']))
seasoneplist.sort(key=lambda seep: seep[1])
seasoneplist.sort(key=lambda seep: seep[0])
for seepitem in seasoneplist:
fileList.append(seepitem[2])
elif sort.get("method","") == 'random':
self.log("[%s] buildList, random shuffling"%(citem['id']))
dirList = randomShuffle(dirList)
fileList = randomShuffle(fileList)
self.getTrailers(trailersdict)
self.log("[%s] buildList, returning (%s) files, (%s) dirs; parsed (%s) trailers"%(citem['id'],len(fileList),len(dirList),len(trailersdict)))
return fileList, dirList, nlimits, errors
def isHD(self, item: dict) -> bool:
if 'isHD' in item: return item['isHD']
elif not item.get('streamdetails',{}).get('video',[]) and not item.get('file','').startswith(tuple(VFS_TYPES)):
item['streamdetails'] = self.jsonRPC.getStreamDetails(item.get('file'), item.get('media','video'))
details = item.get('streamdetails',{})
if 'video' in details and len(details.get('video')) > 0:
videowidth = int(details['video'][0]['width'] or '0')
videoheight = int(details['video'][0]['height'] or '0')
if videowidth >= 1280 or videoheight >= 720: return True
return False
def isUHD(self, item: dict) -> bool:
if 'isUHD' in item: return item['isUHD']
elif not item.get('streamdetails',{}).get('video',[]) and not item.get('file','').startswith(tuple(VFS_TYPES)):
item['streamdetails'] = self.jsonRPC.getStreamDetails(item.get('file'), item.get('media','video'))
details = item.get('streamdetails',{})
if 'video' in details and len(details.get('video')) > 0:
videowidth = int(details['video'][0]['width'] or '0')
videoheight = int(details['video'][0]['height'] or '0')
if videowidth > 1920 or videoheight > 1080: return True
return False
def is3D(self, item: dict) -> bool:
if 'is3D' in item: return item['is3D']
elif not item.get('streamdetails',{}).get('video',[]) and not item.get('file','').startswith(tuple(VFS_TYPES)):
item['streamdetails'] = self.jsonRPC.getStreamDetails(item.get('file'), item.get('media','video'))
details = item.get('streamdetails',{})
if 'video' in details and details.get('video') != [] and len(details.get('video')) > 0:
stereomode = (details['video'][0]['stereomode'] or [])
if len(stereomode) > 0: return True
return False
def getTrailers(self, nitems: dict={}) -> dict:
return self.cache.set('kodiTrailers', mergeDictLST((self.cache.get('kodiTrailers', json_data=True) or {}),nitems), expiration=datetime.timedelta(days=28), json_data=True)

View File

@@ -0,0 +1,115 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
#
# -*- coding: utf-8 -*-
from globals import *
from functools import wraps
from fileaccess import FileAccess
try: from simplecache import SimpleCache
except: from simplecache.simplecache import SimpleCache #pycharm stub
def cacheit(expiration=datetime.timedelta(days=MIN_GUIDEDAYS), checksum=ADDON_VERSION, json_data=False):
def internal(method):
@wraps(method)
def wrapper(*args, **kwargs):
method_class = args[0]
cacheName = "%s.%s"%(method_class.__class__.__name__, method.__name__)
for item in args[1:]: cacheName += u".%s"%item
for k, v in list(kwargs.items()): cacheName += u".%s"%(v)
results = method_class.cache.get(cacheName.lower(), checksum, json_data)
if results: return results
return method_class.cache.set(cacheName.lower(), method(*args, **kwargs), checksum, expiration, json_data)
return wrapper
return internal
class Service:
monitor = MONITOR()
def _interrupt(self) -> bool:
return xbmcgui.Window(10000).getProperty('%s.pendingInterrupt'%(ADDON_ID)) == "true"
def _suspend(self) -> bool:
return xbmcgui.Window(10000).getProperty('%s.suspendActivity'%(ADDON_ID)) == "true"
class Cache:
lock = Lock()
cache = SimpleCache()
service = Service()
@contextmanager
def cacheLocker(self, wait=0.0001): #simplecache is not thread safe, threadlock not avoiding collisions? Hack/Lazy avoidance.
while not self.service.monitor.abortRequested():
if self.service.monitor.waitForAbort(wait) or self.service._interrupt(): break
elif xbmcgui.Window(10000).getProperty('%s.cacheLocker'%(ADDON_ID)) != 'true': break
xbmcgui.Window(10000).setProperty('%s.cacheLocker'%(ADDON_ID),'true')
try: yield
finally:
xbmcgui.Window(10000).setProperty('%s.cacheLocker'%(ADDON_ID),'false')
def __init__(self, mem_cache=False, is_json=False, disable_cache=False):
self.cache.enable_mem_cache = mem_cache
self.cache.data_is_json = is_json
self.disable_cache = (disable_cache | REAL_SETTINGS.getSettingBool('Disable_Cache'))
def log(self, msg, level=xbmc.LOGDEBUG):
log('%s: %s'%(self.__class__.__name__,msg),level)
def getname(self, name):
if not name.startswith(ADDON_ID): name = '%s.%s'%(ADDON_ID,name)
return name.lower()
def set(self, name, value, checksum=ADDON_VERSION, expiration=datetime.timedelta(minutes=15), json_data=False):
if not self.disable_cache or (not isinstance(value,(bool,list,dict)) and not value):
with self.cacheLocker():
self.log('set, name = %s, value = %s'%(self.getname(name),'%s...'%(str(value)[:128])))
self.cache.set(self.getname(name),value,checksum,expiration,json_data)
return value
def get(self, name, checksum=ADDON_VERSION, json_data=False):
if not self.disable_cache:
with self.cacheLocker():
try:
value = self.cache.get(self.getname(name),checksum,json_data)
self.log('get, name = %s, value = %s'%(self.getname(name),'%s...'%(str(value)[:128])))
return value
except Exception as e:
self.log("get, name = %s failed! simplecacheDB %s"%(self.getname(name),e), xbmc.LOGERROR)
self.clear(name)
def clear(self, name, wait=15):
import sqlite3
self.log('clear, name = %s'%self.getname(name))
sc = FileAccess.translatePath(xbmcaddon.Addon(id='script.module.simplecache').getAddonInfo('profile'))
dbpath = os.path.join(sc, 'simplecache.db')
try:
connection = sqlite3.connect(dbpath, timeout=wait, isolation_level=None)
connection.execute('DELETE FROM simplecache WHERE id LIKE ?', (self.getname(name) + '%',))
connection.commit()
except sqlite3.Error as e: self.log('clear, failed! %s' % e, xbmc.LOGERROR)
finally:
if connection:
connection.close()
del connection
del sqlite3

View File

@@ -0,0 +1,162 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
#todo create dataclasses for all jsons
# https://pypi.org/project/dataclasses-json/
class Channels:
def __init__(self):
self.channelDATA = getJSON(CHANNELFLE_DEFAULT)
self.channelTEMP = getJSON(CHANNEL_ITEM)
self.channelDATA.update(self._load())
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def _load(self, file=CHANNELFLEPATH) -> dict:
channelDATA = getJSON(file)
self.log('_load, channels = %s'%(len(channelDATA.get('channels',[]))))
return channelDATA
def _verify(self, channels: list=[]):
for idx, citem in enumerate(self.channelDATA.get('channels',[])):
if not citem.get('name') or not citem.get('id') or len(citem.get('path',[])) == 0:
self.log('_verify, in-valid citem [%s]\n%s'%(citem.get('id'),citem))
continue
else: yield citem
def _save(self, file=CHANNELFLEPATH) -> bool:
self.channelDATA['uuid'] = SETTINGS.getMYUUID()
self.channelDATA['channels'] = self.sortChannels(self.channelDATA['channels'])
self.log('_save, channels = %s'%(len(self.channelDATA['channels'])))
return setJSON(file,self.channelDATA)
def getTemplate(self) -> dict:
return self.channelTEMP.copy()
def getChannels(self) -> list:
return sorted(self.channelDATA['channels'], key=itemgetter('number'))
def popChannels(self, type: str, channels: list=[]) -> list:
return [self.channelDATA['channels'].pop(self.channelDATA['channels'].index(citem)) for citem in list([c for c in channels if c.get('type') == type])]
def getCustom(self) -> list:
channels = self.getChannels()
return list([citem for citem in channels if citem.get('number') <= CHANNEL_LIMIT])
def getAutotuned(self) -> list:
channels = self.getChannels()
return list([citem for citem in channels if citem.get('number') > CHANNEL_LIMIT])
def getChannelbyID(self, id: str) -> list:
channels = self.getChannels()
return list([c for c in channels if c.get('id') == id])
def getType(self, type: str):
channels = self.getChannels()
return list([citem for citem in channels if citem.get('type') == type])
def sortChannels(self, channels: list) -> list:
try: return sorted(channels, key=itemgetter('number'))
except: return channels
def setChannels(self, channels: list=[]) -> bool:
if len(channels) == 0: channels = self.channelDATA['channels']
self.channelDATA['channels'] = channels
SETTINGS.setSetting('Select_Channels','[B]%s[/B] Channels'%(len(channels)))
PROPERTIES.setChannels(len(channels)>0)
return self._save()
def getImports(self) -> list:
return self.channelDATA.get('imports',[])
def setImports(self, data: list=[]) -> bool:
self.channelDATA['imports'] = data
return self.setChannels()
def clearChannels(self):
self.channelDATA['channels'] = []
def delChannel(self, citem: dict={}) -> bool:
self.log('delChannel,[%s]'%(citem['id']), xbmc.LOGINFO)
idx, channel = self.findChannel(citem)
if idx is not None: self.channelDATA['channels'].pop(idx)
return True
def delChannels(self, channels: list=[]) -> bool:
return [self.delChannel(channel) for channel in channels]
def addChannel(self, citem: dict={}) -> bool:
idx, channel = self.findChannel(citem)
if idx is not None:
for key in ['id','rules','number','favorite','logo']:
if channel.get(key): citem[key] = channel[key] # existing id found, reuse channel meta.
if citem.get('favorite',False):
citem['group'].append(LANGUAGE(32019))
citem['group'] = sorted(set(citem['group']))
self.log('addChannel, [%s] updating channel %s'%(citem["id"],citem["name"]), xbmc.LOGINFO)
self.channelDATA['channels'][idx] = citem
else:
self.log('addChannel, [%s] adding channel %s'%(citem["id"],citem["name"]), xbmc.LOGINFO)
self.channelDATA.setdefault('channels',[]).append(citem)
return True
def addChannels(self, channels: list=[]) -> bool:
return [self.addChannel(channel) for channel in channels]
def findChannel(self, citem: dict={}, channels: list=[]) -> tuple:
if len(channels) == 0: channels = self.getChannels()
for idx, eitem in enumerate(channels):
if citem.get('id') == eitem.get('id',str(random.random())):
return idx, eitem
return None, {}
def findAutotuned(self, citem: dict={}, channels: list=[]) -> tuple:
if len(channels) == 0: channels = self.getAutotuned()
for idx, eitem in enumerate(channels):
if (citem.get('id') == eitem.get('id',str(random.random()))) or (citem.get('type') == eitem.get('type',str(random.random())) and citem.get('name','').lower() == eitem.get('name',str(random.random())).lower()):
return idx, eitem
return None, {}

View File

@@ -0,0 +1,385 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
#
# -*- coding: utf-8 -*-
import os
from kodi_six import xbmc, xbmcaddon
#info
ADDON_ID = 'plugin.video.pseudotv.live'
REAL_SETTINGS = xbmcaddon.Addon(id=ADDON_ID)
ADDON_NAME = REAL_SETTINGS.getAddonInfo('name')
ADDON_VERSION = REAL_SETTINGS.getAddonInfo('version')
ICON = REAL_SETTINGS.getAddonInfo('icon')
FANART = REAL_SETTINGS.getAddonInfo('fanart')
SETTINGS_LOC = REAL_SETTINGS.getAddonInfo('profile')
ADDON_PATH = REAL_SETTINGS.getAddonInfo('path')
ADDON_AUTHOR = REAL_SETTINGS.getAddonInfo('author')
ADDON_URL = 'https://raw.githubusercontent.com/PseudoTV/PseudoTV_Live/master/plugin.video.pseudotv.live/addon.xml'
LANGUAGE = REAL_SETTINGS.getLocalizedString
#api
MONITOR = xbmc.Monitor
PLAYER = xbmc.Player
#constants
FIFTEEN = 15 #unit
DISCOVERY_TIMER = 60 #secs
SUSPEND_TIMER = 2 #secs
DISCOVER_INTERVAL = 30 #secs
MIN_EPG_DURATION = 10800 #secs
ONNEXT_TIMER = 15
DTFORMAT = '%Y%m%d%H%M%S'
DTZFORMAT = '%Y%m%d%H%M%S +%z'
DTJSONFORMAT = '%Y-%m-%d %H:%M:%S'
BACKUP_TIME_FORMAT = '%Y-%m-%d %I:%M %p'
LANG = 'en' #todo parse kodi region settings
DEFAULT_ENCODING = "utf-8"
PROMPT_DELAY = 4000 #msecs
AUTOCLOSE_DELAY = 300 #secs
SELECT_DELAY = 900 #secs
RADIO_ITEM_LIMIT = 250
CHANNEL_LIMIT = 999
AUTOTUNE_LIMIT = 3
FILLER_LIMIT = 250
QUEUE_CHUNK = 25
FILLER_TYPE = ['Rating',
'Bumper',
'Advert',
'Trailer',
'Pre-Roll',
'Post-Roll']
FILLER_TYPES = ['Ratings',
'Bumpers',
'Adverts',
'Trailers']
AUTOTUNE_TYPES = ["Playlists",
"TV Networks",
"TV Shows",
"TV Genres",
"Movie Genres",
"Movie Studios",
"Mixed Genres",
"Music Genres",
"Mixed",
"Recommended",
"Services"]
GROUP_TYPES = ['Addon',
'Custom',
'Directory',
'TV',
'Movies',
'Music',
'Miscellaneous',
'PVR',
'Plugin',
'Radio',
'Smartplaylist',
'UPNP',
'IPTV'] + AUTOTUNE_TYPES
DB_TYPES = ["videodb://",
"musicdb://",
"library://",
"special://"]
WEB_TYPES = ["http",
"ftp://",
"pvr://"
"upnp://",]
VFS_TYPES = ["plugin://",
"pvr://",
"resource://",
"special://home/addons/resource"]
TV_TYPES = ['episode',
'episodes',
'tvshow',
'tvshows']
MOVIE_TYPES = ['movie',
'movies']
MUSIC_TYPES = ['songs',
'albums',
'artists',
'music']
HTML_ESCAPE = {"&": "&amp;",
'"': "&quot;",
"'": "&apos;",
">": "&gt;",
"<": "&lt;"}
ALT_PLAYLISTS = [".cue",
".m3u",
".m3u8",
".strm",
".pls",
".wpl"]
IGNORE_CHTYPE = ['TV Shows',
'Mixed',
'Recommended',
'Services',
'Music Genres']
MOVIE_CHTYPE = ["Movie Genres",
"Movie Studios"]
TV_CHTYPE = ["TV Networks",
"TV Genres",
"Mixed Genre"]
TV_URL = 'plugin://{addon}/?mode=tv&name={name}&chid={chid}.pvr'
RESUME_URL = 'plugin://{addon}/?mode=resume&name={name}&chid={chid}.pvr'
RADIO_URL = 'plugin://{addon}/?mode=radio&name={name}&chid={chid}&radio={radio}&vid={vid}.pvr'
LIVE_URL = 'plugin://{addon}/?mode=live&name={name}&chid={chid}&vid={vid}&now={now}&start={start}&duration={duration}&stop={stop}.pvr'
BROADCAST_URL = 'plugin://{addon}/?mode=broadcast&name={name}&chid={chid}&vid={vid}.pvr'
VOD_URL = 'plugin://{addon}/?mode=vod&title={title}&chid={chid}&vid={vid}&name={name}.pvr'
DVR_URL = 'plugin://{addon}/?mode=dvr&title={title}&chid={chid}&vid={vid}&seek={seek}&duration={duration}.pvr'
PTVL_REPO = 'repository.pseudotv'
PVR_CLIENT_ID = 'pvr.iptvsimple'
PVR_CLIENT_NAME = 'IPTV Simple Client'
PVR_CLIENT_LOC = 'special://profile/addon_data/%s'%(PVR_CLIENT_ID)
#docs
README_FLE = os.path.join(ADDON_PATH,'README.md')
CHANGELOG_FLE = os.path.join(ADDON_PATH,'changelog.txt')
LICENSE_FLE = os.path.join(ADDON_PATH,'LICENSE')
#files
M3UFLE = 'pseudotv.m3u'
XMLTVFLE = 'pseudotv.xml'
GENREFLE = 'genres.xml'
REMOTEFLE = 'remote.json'
BONJOURFLE = 'bonjour.json'
SERVERFLE = 'servers.json'
CHANNELFLE = 'channels.json'
LIBRARYFLE = 'library.json'
TVGROUPFLE = 'tv_groups.xml'
RADIOGROUPFLE = 'radio_groups.xml'
PROVIDERFLE = 'providers.xml'
CHANNELBACKUPFLE = 'channels.backup'
CHANNELRESTOREFLE = 'channels.restore'
#exts
VIDEO_EXTS = xbmc.getSupportedMedia('video').split('|')[:-1]
MUSIC_EXTS = xbmc.getSupportedMedia('music').split('|')[:-1]
IMAGE_EXTS = xbmc.getSupportedMedia('picture').split('|')[:-1]
IMG_EXTS = ['.png','.jpg','.gif']
TEXTURES = 'Textures.xbt'
#folders
IMAGE_LOC = os.path.join(ADDON_PATH,'resources','images')
MEDIA_LOC = os.path.join(ADDON_PATH,'resources','skins','default','media')
SFX_LOC = os.path.join(MEDIA_LOC,'sfx')
SERVER_LOC = os.path.join(SETTINGS_LOC,SERVERFLE)
BACKUP_LOC = os.path.join(SETTINGS_LOC,'backup')
CACHE_LOC = os.path.join(SETTINGS_LOC,'cache')
TEMP_LOC = os.path.join(SETTINGS_LOC,'temp')
#file paths
SETTINGS_FLE = os.path.join(SETTINGS_LOC,'settings.xml')
CHANNELFLE_BACKUP = os.path.join(BACKUP_LOC,CHANNELBACKUPFLE)
CHANNELFLE_RESTORE = os.path.join(BACKUP_LOC,CHANNELRESTOREFLE)
#sfx
BING_WAV = os.path.join(SFX_LOC,'bing.wav')
NOTE_WAV = os.path.join(SFX_LOC,'notify.wav')
#remotes
IMPORT_ASSET = os.path.join(ADDON_PATH,'remotes','asset.json')
RULEFLE_ITEM = os.path.join(ADDON_PATH,'remotes','rule.json')
CHANNEL_ITEM = os.path.join(ADDON_PATH,'remotes','channel.json')
M3UFLE_DEFAULT = os.path.join(ADDON_PATH,'remotes','m3u.json')
SEASONS = os.path.join(ADDON_PATH,'remotes','seasons.json')
HOLIDAYS = os.path.join(ADDON_PATH,'remotes','holidays.json')
GROUPFLE_DEFAULT = os.path.join(ADDON_PATH,'remotes','groups.xml')
LIBRARYFLE_DEFAULT = os.path.join(ADDON_PATH,'remotes',LIBRARYFLE)
CHANNELFLE_DEFAULT = os.path.join(ADDON_PATH,'remotes',CHANNELFLE)
GENREFLE_DEFAULT = os.path.join(ADDON_PATH,'remotes',GENREFLE)
PROVIDERFLE_DEFAULT = os.path.join(ADDON_PATH,'remotes',PROVIDERFLE)
#colors
PRIMARY_BACKGROUND = 'FF11375C'
SECONDARY_BACKGROUND = '334F4F9E'
DIALOG_TINT = 'FF181B1E'
BUTTON_FOCUS = 'FF2866A4'
SELECTED = 'FF5BE5EE'
COLOR_BACKGROUND = '01416b'
COLOR_TEXT = 'FFFFFF'
COLOR_UNAVAILABLE_CHANNEL = 'dimgray'
COLOR_AVAILABLE_CHANNEL = 'white'
COLOR_LOCKED_CHANNEL = 'orange'
COLOR_WARNING_CHANNEL = 'red'
COLOR_NEW_CHANNEL = 'green'
COLOR_RADIO_CHANNEL = 'cyan'
COLOR_FAVORITE_CHANNEL = 'yellow'
#https://github.com/xbmc/xbmc/blob/656052d108297e4dd8c5c6fc7db86606629e457e/system/colors.xml
#urls
URL_WIKI = 'https://github.com/PseudoTV/PseudoTV_Live/wiki'
URL_SUPPORT = 'https://forum.kodi.tv/showthread.php?tid=346803'
URL_WIN_BONJOUR = 'https://support.apple.com/en-us/106380'
URL_README = 'https://github.com/PseudoTV/PseudoTV_Live/blob/master/plugin.video.pseudotv.live/README.md'
# https://github.com/xbmc/xbmc/blob/master/system/colors.xml
#images
LOGO = os.path.join(MEDIA_LOC,'wlogo.png')
DIM_LOGO = os.path.join(MEDIA_LOC,'dimlogo.png')
COLOR_LOGO = os.path.join(MEDIA_LOC,'logo.png')
COLOR_FANART = os.path.join(MEDIA_LOC,'fanart.jpg')
HOST_LOGO = 'http://github.com/PseudoTV/PseudoTV_Live/blob/master/plugin.video.pseudotv.live/resources/skins/default/media/logo.png?raw=true'
DUMMY_ICON = 'https://dummyimage.com/512x512/%s/%s.png&text={text}'%(COLOR_BACKGROUND,COLOR_TEXT)
#skins
BUSY_XML = '%s.busy.xml'%(ADDON_ID)
ONNEXT_XML = '%s.onnext.xml'%(ADDON_ID)
RESTART_XML = '%s.restart.xml'%(ADDON_ID)
ONNEXT_XML = '%s.onnext.xml'%(ADDON_ID)
BACKGROUND_XML = '%s.background.xml'%(ADDON_ID)
MANAGER_XML = '%s.manager.xml'%(ADDON_ID)
OVERLAYTOOL_XML = '%s.overlaytool.xml'%(ADDON_ID)
DIALOG_SELECT = '%s.dialogselect.xml'%(ADDON_ID)
# https://github.com/xbmc/xbmc/blob/master/xbmc/addons/kodi-dev-kit/include/kodi/c-api/gui/input/action_ids.h
# Actions
ACTION_MOVE_LEFT = 1
ACTION_MOVE_RIGHT = 2
ACTION_MOVE_UP = 3
ACTION_MOVE_DOWN = 4
ACTION_INVALID = 999
ACTION_SELECT_ITEM = [7,135]
ACTION_PREVIOUS_MENU = [92,10,110,521,ACTION_SELECT_ITEM]
#rules
##builder
RULES_ACTION_CHANNEL_CITEM = 1 #Persistent citem changes
RULES_ACTION_CHANNEL_START = 2 #Set channel global
RULES_ACTION_CHANNEL_BUILD_FILEARRAY_PRE = 3 #Initial FileArray (build bypass)
RULES_ACTION_CHANNEL_BUILD_PATH = 4 #Alter parsing directory prior to build
RULES_ACTION_CHANNEL_BUILD_FILELIST_PRE = 5 #Initial FileList prior to fillers, after interleaving
RULES_ACTION_CHANNEL_BUILD_FILELIST_POST = 6 #Final FileList after fillers
RULES_ACTION_CHANNEL_BUILD_TIME_PRE = 7 #FileList prior to scheduling
RULES_ACTION_CHANNEL_BUILD_TIME_POST = 8 #FileList after scheduling
RULES_ACTION_CHANNEL_BUILD_FILEARRAY_POST = 9 #FileArray prior to interleaving and fillers
RULES_ACTION_CHANNEL_STOP = 10#restore channel global
RULES_ACTION_CHANNEL_TEMP_CITEM = 11 #Temporary citem changes, rule injection
RULES_ACTION_CHANNEL_BUILD_FILELIST_RETURN = 12
##player
RULES_ACTION_PLAYER_START = 20 #Playback started
RULES_ACTION_PLAYER_CHANGE = 21 #Playback changed/ended
RULES_ACTION_PLAYER_STOP = 22 #Playback stopped
##overlay/background
RULES_ACTION_OVERLAY_OPEN = 30 #Overlay opened
RULES_ACTION_OVERLAY_CLOSE = 31 #Overlay closed
##playback
RULES_ACTION_PLAYBACK_RESUME = 40 #Prior to playback trigger resume to received a FileList
HEADER = {'User-Agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246"}
MUSIC_LISTITEM_TYPES = {'tracknumber' : (int,), #integer (8)
'discnumber' : (int,), #integer (2)
'duration' : (int,), #integer (245) - duration in seconds
'year' : (int,), #integer (1998)
'genre' : (tuple,list),
'album' : (str,),
'artist' : (str,),
'title' : (str,),
'rating' : (float,),#float - range is between 0 and 10
'userrating' : (int,), #integer - range is 1..10
'lyrics' : (str,),
'playcount' : (int,), #integer (2) - number of times this item has been played
'lastplayed' : (str,), #string (Y-m-d h:m:s = 2009-04-05 23:16:04)
'mediatype' : (str,), #string - "music", "song", "album", "artist"
'dbid' : (int,), #integer (23) - Only add this for items which are part of the local db. You also need to set the correct 'mediatype'!
'listeners' : (int,), #integer (25614)
'musicbrainztrackid' : (str,list),
'musicbrainzartistid' : (str,list),
'musicbrainzalbumid' : (str,list),
'musicbrainzalbumartistid': (str,list),
'comment' : (str,),
'count' : (int,), #integer (12) - can be used to store an id for later, or for sorting purposes
# 'size' : (int,), #long (1024) - size in bytes
'date' : (str,),} #string (d.m.Y / 01.01.2009) - file date
VIDEO_LISTITEM_TYPES = {'genre' : (tuple,list),
'country' : (str,list),
'year' : (int,), #integer (2009)
'episode' : (int,), #integer (4)
'season' : (int,), #integer (1)
'sortepisode' : (int,), #integer (4)
'sortseason' : (int,), #integer (1)
'episodeguide' : (str,),
'showlink' : (str,list),
'top250' : (int,), #integer (192)
'setid' : (int,), #integer (14)
'tracknumber' : (int,), #integer (3)
'rating' : (float,),#float (6.4) - range is 0..10
'userrating' : (int,), #integer (9) - range is 1..10 (0 to reset)
'playcount' : (int,), #integer (2) - number of times this item has been played
'overlay' : (int,), #integer (2) - range is 0..7. See Overlay icon types for values
'cast' : (list,),
'castandrole' : (list,tuple),
'director' : (str,list),
'mpaa' : (str,),
'plot' : (str,),
'plotoutline' : (str,),
'title' : (str,),
'originaltitle' : (str,),
'sorttitle' : (str,),
'duration' : (int,), #integer (245) - duration in seconds
'studio' : (str,list),
'tagline' : (str,),
'writer' : (str,list),
'tvshowtitle' : (str,list),
'premiered' : (str,), #string (2005-03-04)
'status' : (str,),
'set' : (str,),
'setoverview' : (str,),
'tag' : (str,list),
'imdbnumber' : (str,), #string (tt0110293) - IMDb code
'code' : (str,), #string (101) - Production code
'aired' : (str,), #string (2008-12-07)
'credits' : (str,list),
'lastplayed' : (str,), #string (Y-m-d h:m:s = 2009-04-05 23:16:04)
'album' : (str,),
'artist' : (list,),
'votes' : (str,),
'path' : (str,),
'trailer' : (str,),
'dateadded' : (str,), #string (Y-m-d h:m:s = 2009-04-05 23:16:04)
'mediatype' : (str,), #mediatype string - "video", "movie", "tvshow", "season", "episode" or "musicvideo"
'dbid' : (int,), #integer (23) - Only add this for items which are part of the local db. You also need to set the correct 'mediatype'!
'count' : (int,), #integer (12) - can be used to store an id for later, or for sorting purposes
# 'size' : (int,), #long (1024) - size in bytes
'date' : (str,),} #string (d.m.Y / 01.01.2009) - file date

View File

@@ -0,0 +1,68 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from manager import Manager
class Create:
def __init__(self, sysARG: dict={}, listitem: xbmcgui.ListItem=xbmcgui.ListItem(), fitem: dict={}):
log('Create: __init__, sysARG = %s, fitem = %s\npath = %s'%(sysARG,fitem,listitem.getPath()))
self.sysARG = sysARG
self.fitem = fitem
self.listitem = listitem
def add(self):
if not self.listitem.getPath(): return DIALOG.notificationDialog(LANGUAGE(32030))
elif DIALOG.yesnoDialog('Would you like to add:\n[B]%s[/B]\nto the first available %s channel?'%(self.listitem.getLabel(),ADDON_NAME)):
if not PROPERTIES.isRunning('Create.add'):
with PROPERTIES.chkRunning('Create.add'), BUILTIN.busy_dialog(), PROPERTIES.interruptActivity():
manager = Manager(MANAGER_XML, ADDON_PATH, "default", start=False, channel=-1)
channelData = manager.newChannel
channelData['type'] = 'Custom'
channelData['favorite'] = True
channelData['number'] = manager.getFirstAvailChannel()
channelData['radio'] = True if self.listitem.getPath().startswith('musicdb://') else False
channelData['name'], channelData = manager.validateLabel(cleanLabel(self.listitem.getLabel()),channelData)
path, channelData = manager.validatePath(unquoteString(self.listitem.getPath()),channelData,spinner=False)
if path is None: return
channelData['path'] = [path.strip('/')]
channelData['id'] = getChannelID(channelData['name'], channelData['path'], channelData['number'])
manager.channels.addChannel(channelData)
manager.channels.setChannels()
PROPERTIES.setUpdateChannels(channelData['id'])
manager.closeManager()
del manager
manager = Manager(MANAGER_XML, ADDON_PATH, "default", channel=channelData['number'])
del manager
def open(self):
if not PROPERTIES.isRunning('Create.open'):
with PROPERTIES.chkRunning('Create.open'), BUILTIN.busy_dialog(), PROPERTIES.interruptActivity():
manager = Manager(MANAGER_XML, ADDON_PATH, "default", channel=self.fitem.get('citem',{}).get('number',1))
del manager
if __name__ == '__main__':
log('Create: __main__, param = %s'%(sys.argv))
try: mode = sys.argv[1]
except: mode = ''
if mode == 'manage': Create(sys.argv,listitem=sys.listitem,fitem=decodePlot(BUILTIN.getInfoLabel('Plot'))).open()
else: Create(sys.argv,listitem=sys.listitem,fitem=decodePlot(BUILTIN.getInfoLabel('Plot'))).add()

View File

@@ -0,0 +1,87 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from seasonal import Seasonal
class Info:
def __init__(self, sysARG: dict={}, listitem: xbmcgui.ListItem=xbmcgui.ListItem(), fitem: dict={}):
with BUILTIN.busy_dialog():
log('Info: __init__, sysARG = %s'%(sysARG))
listitem = LISTITEMS.buildItemListItem(fitem,fitem.get('media','video'),oscreen=True)
DIALOG.infoDialog(listitem)
class Browse:
def __init__(self, sysARG: dict={}, listitem: xbmcgui.ListItem=xbmcgui.ListItem(), fitem: dict={}):
log('Browse: __init__, sysARG = %s'%(sysARG))
with BUILTIN.busy_dialog():
media = '%ss'%(fitem.get('media','video'))
path = fitem.get('citem',{}).get('path')
if isinstance(path,list): path = path[0]
if '?xsp=' in path:
path, params = path.split('?xsp=')
path = '%s?xsp=%s'%(path,quoteString(unquoteString(params)))
#todo create custom container window with channel listitems.
log('Browse: target = %s, path = %s'%(media,path))
BUILTIN.executebuiltin('ReplaceWindow(%s,%s,return)'%(media,path))
class Match:
SEARCH_SCRIPT = None
GLOBAL_SCRIPT = 'script.globalsearch'
SIMILAR_SCRIPT = 'script.embuary.helper'
def __init__(self, sysARG: dict={}, listitem: xbmcgui.ListItem=xbmcgui.ListItem(), fitem: dict={}):
with BUILTIN.busy_dialog():
title = BUILTIN.getInfoLabel('Title')
name = BUILTIN.getInfoLabel('EpisodeName')
dbtype = fitem.get('type').replace('episodes','tvshow').replace('tvshows','tvshow').replace('movies','movie')
dbid = (fitem.get('tvshowid') or fitem.get('movieid'))
log('Match: __init__, sysARG = %s, title = %s, dbtype = %s, dbid = %s'%(sysARG,'%s - %s'%(title,name),dbtype,dbid))
if hasAddon(self.SIMILAR_SCRIPT,install=True) and dbid:
self.SEARCH_SCRIPT = self.SIMILAR_SCRIPT
elif hasAddon(self.GLOBAL_SCRIPT,install=True):
self.SEARCH_SCRIPT = self.GLOBAL_SCRIPT
else:
DIALOG.notificationDialog(LANGUAGE(32000))
log('Match: SEARCH_SCRIPT = %s'%(self.SEARCH_SCRIPT))
hasAddon(self.SEARCH_SCRIPT,enable=True)
if self.SEARCH_SCRIPT == self.SIMILAR_SCRIPT:
# plugin://script.embuary.helper/?info=getsimilar&dbid=$INFO[ListItem.DBID]&type=tvshow&tag=HDR
# plugin://script.embuary.helper/?info=getsimilar&dbid=$INFO[ListItem.DBID]&type=movie&tag=HDR
# tag = optional, additional filter option to filter by library tag
BUILTIN.executebuiltin('ReplaceWindow(%s,%s,return)'%('%ss'%(fitem.get('media','video')),'plugin://%s/?info=getsimilar&dbid=%d&type=%s'%(self.SEARCH_SCRIPT,dbid,dbtype)))
else:
# - the addon is executed by another addon/skin: RunScript(script.globalsearch,searchstring=foo)
# You can specify which categories should be searched (this overrides the user preferences set in the addon settings):
# RunScript(script.globalsearch,movies=true)
# RunScript(script.globalsearch,tvshows=true&amp;musicvideos=true&amp;songs=true)
# availableeoptions: movies, tvshows, episodes, musicvideos, artists, albums, songs, livetv, actors, directors
BUILTIN.executebuiltin('RunScript(%s)'%('%s,searchstring=%s'%(self.SEARCH_SCRIPT,escapeString('%s,movies=True,episodes=True,tvshows=True,livetv=True'%(quoteString(title))))))
if __name__ == '__main__':
param = sys.argv[1]
log('Info: __main__, param = %s'%(param))
if param == 'info': Info(sys.argv ,listitem=sys.listitem,fitem=decodePlot(BUILTIN.getInfoLabel('Plot')))
elif param == 'browse': Browse(sys.argv,listitem=sys.listitem,fitem=decodePlot(BUILTIN.getInfoLabel('Plot')))
elif param == 'match': Match(sys.argv ,listitem=sys.listitem,fitem=decodePlot(BUILTIN.getInfoLabel('Plot')))

View File

@@ -0,0 +1,40 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from plugin import Plugin
def run(sysARG, fitem: dict={}, nitem: dict={}):
with BUILTIN.busy_dialog():
mode = sysARG[1]
params = {}
params['fitem'] = fitem
params['nitem'] = nitem
params['vid'] = decodeString(params.get("vid",''))
params["chid"] = (params.get("chid") or fitem.get('citem',{}).get('id'))
params['title'] = (params.get('title') or BUILTIN.getInfoLabel('label'))
params['name'] = (unquoteString(params.get("name",'')) or fitem.get('citem',{}).get('name') or BUILTIN.getInfoLabel('ChannelName'))
params['isPlaylist'] = (mode == 'playlist')
log("Context_Play: run, params = %s"%(params))
if mode == 'play': threadit(Plugin(sysARG, sysInfo=params).playTV)(params["name"],params["chid"])
elif mode == 'playlist': threadit(Plugin(sysARG, sysInfo=params).playPlaylist)(params["name"],params["chid"])
if __name__ == '__main__': run(sys.argv, fitem=decodePlot(BUILTIN.getInfoLabel('Plot')), nitem=decodePlot(BUILTIN.getInfoLabel('NextPlot')))

View File

@@ -0,0 +1,82 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from m3u import M3U
from xmltvs import XMLTVS
class Record:
def __init__(self, sysARG: dict={}, listitem: xbmcgui.ListItem=xbmcgui.ListItem(), fitem: dict={}):
with BUILTIN.busy_dialog():
log('Record: __init__, sysARG = %s, fitem = %s\npath = %s'%(sysARG,fitem,listitem.getPath()))
self.sysARG = sysARG
self.fitem = fitem
self.listitem = listitem
self.fitem['label'] = (fitem.get('label') or listitem.getLabel())
def add(self):
if not PROPERTIES.isRunning('Record.add'):
with PROPERTIES.chkRunning('Record.add'):
now = timeString2Seconds(BUILTIN.getInfoLabel('Time(hh:mm:ss)','System'))
start = timeString2Seconds(BUILTIN.getInfoLabel('StartTime').split(' ')[0] +':00')
stop = timeString2Seconds(BUILTIN.getInfoLabel('EndTime').split(' ')[0] +':00')
if (now > start and now < stop):
opt ='Incl. Resume'
seek = (now - start) - OSD_TIMER #add rollback buffer
msg = '%s or %s'%(LANGUAGE(30119),LANGUAGE(30152))
else:
opt = ''
seek = 0
msg = LANGUAGE(30119)
retval = DIALOG.yesnoDialog('Would you like to add:\n[B]%s[/B]\nto %s recordings?'%(self.fitem['label'],msg),customlabel=opt)
if retval or int(retval) > 0:
with BUILTIN.busy_dialog(), PROPERTIES.interruptActivity():
m3u = M3U()
ritem = m3u.getRecordItem(self.fitem, seek)
added = (m3u.addRecording(ritem), XMLTVS().addRecording(ritem,self.fitem))
del m3u
if added:
log('Record: add, ritem = %s'%(ritem))
DIALOG.notificationDialog('%s\n%s'%(ritem['label'],LANGUAGE(30116)))
PROPERTIES.setPropTimer('chkPVRRefresh')
else: DIALOG.notificationDialog(LANGUAGE(32000))
def remove(self):
if not PROPERTIES.isRunning('Record.remove'):
with PROPERTIES.chkRunning('Record.remove'):
if DIALOG.yesnoDialog('Would you like to remove:\n[B]%s[/B]\nfrom recordings?'%(self.fitem['label'])):
with BUILTIN.busy_dialog(), PROPERTIES.interruptActivity():
ritem = (self.fitem.get('citem') or {"name":self.fitem['label'],"path":self.listitem.getPath()})
removed = (M3U().delRecording(ritem), XMLTVS().delRecording(ritem))
if removed:
log('Record: remove, ritem = %s'%(ritem))
DIALOG.notificationDialog('%s\n%s'%(ritem['name'],LANGUAGE(30118)))
PROPERTIES.setPropTimer('chkPVRRefresh')
else: DIALOG.notificationDialog(LANGUAGE(32000))
if __name__ == '__main__':
try: param = sys.argv[1]
except: param = None
log('Record: __main__, param = %s'%(param))
if param == 'add': Record(sys.argv,listitem=sys.listitem,fitem=decodePlot(BUILTIN.getInfoLabel('Plot'))).add()
elif param == 'del': Record(sys.argv,listitem=sys.listitem,fitem=decodePlot(BUILTIN.getInfoLabel('Plot'))).remove()

View File

@@ -0,0 +1,207 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
#
# -*- coding: utf-8 -*-
from globals import *
from pool import ExecutorPool
from collections import defaultdict
class LlNode:
def __init__(self, package: tuple, priority: int=0, delay: int=0):
self.prev = None
self.next = None
self.package = package
self.priority = priority
self.wait = delay
class CustomQueue:
isRunning = False
def __init__(self, fifo: bool=False, lifo: bool=False, priority: bool=False, delay: bool=False, service=None):
self.log("__init__, fifo = %s, lifo = %s, priority = %s, delay = %s"%(fifo, lifo, priority, delay))
self.service = service
self.lock = Lock()
self.fifo = fifo
self.lifo = lifo
self.priority = priority
self.delay = delay
self.head = None
self.tail = None
self.qsize = 0
self.min_heap = []
self.itemCount = defaultdict(int)
self.popThread = Thread(target=self.__pop)
self.pool = ExecutorPool()
self.executor = SETTINGS.getSettingBool('Enable_Executors')
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def __manage(self, thread, target_function, name, daemon=True):
if thread.is_alive():
if hasattr(thread, 'cancel'): thread.cancel()
try: thread.join()
except Exception: pass
new_thread = Thread(target=target_function)
new_thread.name = name
new_thread.daemon = daemon
new_thread.start()
return new_thread
def __start(self):
self.log("__starting popThread")
self.popThread = self.__manage(self.popThread, self.__pop, "popThread")
def __run(self, func, *args, **kwargs):
self.log(f"__run, func = {func.__name__}, executor = {self.executor}")
try:
if self.executor:
return self.pool.executor(func, None, *args, **kwargs)
else:
thread = Thread(target=func, args=args, kwargs=kwargs)
thread.start()
except Exception as e:
self.log(f"__run, func = {func.__name__} failed! {e}\nargs = {args}, kwargs = {kwargs}", xbmc.LOGERROR)
def __exists(self, priority, package):
for idx, item in enumerate(self.min_heap):
epriority,_,epackage = item
if package == epackage:
if priority < epriority:
try:
self.min_heap.pop(idx)
heapq.heapify(self.min_heap) # Ensure heap property is maintained
self.log("__exists, replacing queue: func = %s, priority %s => %s"%(epackage[0].__name__,epriority,priority))
except: self.log("__exists, replacing queue: func = %s, idx = %s failed!"%(epackage[0].__name__,idx))
else: return True
return False
def _push(self, package: tuple, priority: int = 0, delay: int = 0):
node = LlNode(package, priority, delay)
if self.priority:
if not self.__exists(priority, package):
try:
self.qsize += 1
self.itemCount[priority] += 1
self.log(f"_push, func = {package[0].__name__}, priority = {priority}")
heapq.heappush(self.min_heap, (priority, self.itemCount[priority], package))
except Exception as e:
self.log(f"_push, func = {package[0].__name__} failed! {e}", xbmc.LOGFATAL)
else:
if self.head:
self.tail.next = node
node.prev = self.tail
self.tail = node
else:
self.head = node
self.tail = node
self.log(f"_push, func = {package[0].__name__}")
if not self.isRunning:
self.log("_push, starting __pop")
self.__start()
def __process(self, node, fifo=True):
package = node.package
self.log(f"process_node, package = {package}")
next_node = node.__next__ if fifo else node.prev
if next_node: next_node.prev = None if fifo else next_node.prev
if node.prev: node.prev.next = None if fifo else node.prev
if fifo: self.head = next_node
else: self.tail = next_node
return package
def __pop(self):
self.isRunning = True
self.log("__pop, starting")
self.executor = SETTINGS.getSettingBool('Enable_Executors')
while not self.service.monitor.abortRequested():
if self.service.monitor.waitForAbort(0.0001):
self.log("__pop, waitForAbort")
break
elif self.service._interrupt():
self.log("__pop, _interrupt")
break
elif self.service._suspend():
self.log("__pop, _suspend")
self.service.monitor.waitForAbort(SUSPEND_TIMER)
continue
elif not self.head and not self.priority:
self.log("__pop, The queue is empty!")
break
elif self.priority:
if not self.min_heap:
self.log("__pop, The priority queue is empty!")
break
else:
try: priority, _, package = heapq.heappop(self.min_heap)
except Exception as e: continue
self.qsize -= 1
self.__run(package[0],*package[1],**package[2])
elif self.fifo or self.lifo:
curr_node = self.head if self.fifo else self.tail
if curr_node is None:
break
else:
package = self.__process(curr_node, fifo=self.fifo)
if not self.delay: self.__run(*package)
else: timerit(curr_node.wait, [*package])
else:
self.log("__pop, queue undefined!")
break
self.isRunning = False
self.log("__pop, finished: shutting down!")
# def quePriority(package: tuple, priority: int=0):
# q_priority = CustomQueue(priority=True)
# q_priority.log("quePriority")
# q_priority._push(package, priority)
# def queFIFO(package: tuple, delay: int=0):
# q_fifo = CustomQueue(fifo=True, delay=bool(delay))
# q_fifo.log("queFIFO")
# q_fifo._push(package, delay)
# def queLIFO(package: tuple, delay: int=0):
# q_lifo = CustomQueue(lifo=True, delay=bool(delay))
# q_lifo.log("queLIFO")
# q_lifo._push(package, delay)
# def queThread(packages, delay=0):
# q_fifo = CustomQueue(fifo=True)
# q_fifo.log("queThread")
# def thread_function(*package):
# q_fifo._push(package)
# for package in packages:
# t = Thread(target=thread_function, args=(package))
# t.daemon = True
# t.start()

View File

@@ -0,0 +1,37 @@
# Copyright (C) 2024 Lunatixz
# This file is part of PseudoTV Live.
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
class Channel():
def __init__(self, id: str="", type: str="", number: int=0, name: str="", logo: str="", path: list=[], group: list=[], rules: list=[], catchup: str="vod", radio: bool=False, favorite: bool=False):
self.id = id
self.type = type
self.number = number
self.name = name
self.logo = logo
self.path = path
self.group = group
self.rules = rules
self.catchup = catchup
self.radio = radio
self.favorite = favorite
#todo convert json to dataclasses https://dataclass-wizard.readthedocs.io/en/latest/

View File

@@ -0,0 +1,137 @@
from dataclasses import asdict
from typing import List, Dict
import random
from operator import itemgetter
from globals import getJSON, setJSON, SETTINGS, PROPERTIES, LANGUAGE, xbmc, log
@dataclass
class Channel:
id: str
type: str
number: int
name: str
logo: str
path: List[str] = field(default_factory=list)
group: List[str] = field(default_factory=list)
rules: Dict = field(default_factory=dict)
catchup: str = "vod"
radio: bool = False
favorite: bool = False
class Channels:
def __init__(self):
self.channelDATA: Dict[str, List[Channel]] = getJSON(CHANNELFLE_DEFAULT)
self.channelTEMP: Dict = getJSON(CHANNEL_ITEM)
self.channelDATA.update(self._load())
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s' % (self.__class__.__name__, msg), level)
def _load(self, file=CHANNELFLEPATH) -> Dict[str, List[Channel]]:
channelDATA = getJSON(file)
self.log('_load, channels = %s' % (len(channelDATA.get('channels', []))))
return channelDATA
def _verify(self, channels: List[Channel] = []):
for idx, citem in enumerate(self.channelDATA.get('channels', [])):
if not citem.name or not citem.id or len(citem.path) == 0:
self.log('_verify, in-valid citem [%s]\n%s' % (citem.id, citem))
continue
else:
yield citem
def _save(self, file=CHANNELFLEPATH) -> bool:
self.channelDATA['uuid'] = SETTINGS.getMYUUID()
self.channelDATA['channels'] = self.sortChannels(self.channelDATA['channels'])
self.log('_save, channels = %s' % (len(self.channelDATA['channels'])))
return setJSON(file, self.channelDATA)
def getTemplate(self) -> Dict:
return self.channelTEMP.copy()
def getChannels(self) -> List[Channel]:
return sorted(self.channelDATA['channels'], key=itemgetter('number'))
def popChannels(self, type: str, channels: List[Channel] = []) -> List[Channel]:
return [self.channelDATA['channels'].pop(self.channelDATA['channels'].index(citem)) for citem in list([c for c in channels if c.type == type])]
def getCustom(self) -> List[Channel]:
channels = self.getChannels()
return list([citem for citem in channels if citem.number <= CHANNEL_LIMIT])
def getAutotuned(self) -> List[Channel]:
channels = self.getChannels()
return list([citem for citem in channels if citem.number > CHANNEL_LIMIT])
def getChannelbyID(self, id: str) -> List[Channel]:
channels = self.getChannels()
return list([c for c in channels if c.id == id])
def getType(self, type: str) -> List[Channel]:
channels = self.getChannels()
return list([citem for citem in channels if citem.type == type])
def sortChannels(self, channels: List[Channel]) -> List[Channel]:
try:
return sorted(channels, key=itemgetter('number'))
except:
return channels
def setChannels(self, channels: List[Channel] = []) -> bool:
if len(channels) == 0:
channels = self.channelDATA['channels']
self.channelDATA['channels'] = channels
SETTINGS.setSetting('Select_Channels', '[B]%s[/B] Channels' % (len(channels)))
PROPERTIES.setChannels(len(channels) > 0)
return self._save()
def getImports(self) -> List:
return self.channelDATA.get('imports', [])
def setImports(self, data: List = []) -> bool:
self.channelDATA['imports'] = data
return self.setChannels()
def clearChannels(self):
self.channelDATA['channels'] = []
def delChannel(self, citem: Channel) -> bool:
self.log('delChannel,[%s]' % (citem.id), xbmc.LOGINFO)
idx, channel = self.findChannel(citem)
if idx is not None:
self.channelDATA['channels'].pop(idx)
return True
def addChannel(self, citem: Channel) -> bool:
idx, channel = self.findChannel(citem)
if idx is not None:
for key in ['id', 'rules', 'number', 'favorite', 'logo']:
if getattr(channel, key):
setattr(citem, key, getattr(channel, key)) # existing id found, reuse channel meta.
if citem.favorite:
citem.group.append(LANGUAGE(32019))
citem.group = sorted(set(citem.group))
self.log('addChannel, [%s] updating channel %s' % (citem.id, citem.name), xbmc.LOGINFO)
self.channelDATA['channels'][idx] = citem
else:
self.log('addChannel, [%s] adding channel %s' % (citem.id, citem.name), xbmc.LOGINFO)
self.channelDATA.setdefault('channels', []).append(citem)
return True
def findChannel(self, citem: Channel, channels: List[Channel] = []) -> tuple:
if len(channels) == 0:
channels = self.getChannels()
for idx, eitem in enumerate(channels):
if citem.id == eitem.id:
return idx, eitem
return None, {}
def findAutotuned(self, citem: Channel, channels: List[Channel] = []) -> tuple:
if len(channels) == 0:
channels = self.getAutotuned()
for idx, eitem in enumerate(channels):
if citem.id == eitem.id or (citem.type == eitem.type and citem.name.lower() == eitem.name.lower()):
return idx, eitem
return None, {}

View File

@@ -0,0 +1,26 @@
# Copyright (C) 2024 Lunatixz
# This file is part of PseudoTV Live.
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
class Program():
def __init__(self):
...
#todo convert json to dataclasses https://dataclass-wizard.readthedocs.io/en/latest/

View File

@@ -0,0 +1,52 @@
# Copyright (C) 2024 Lunatixz
# This file is part of PseudoTV Live.
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
class Station():
def __init__(self):
self.id = id
self.number = number
self.name = name
self.logo = logo
self.group = group
self.catchup = catchup
self.radio = radio
self.favorite = favorite
self.realtime = realtime
self.media = media
self.label = label
self.url = url
self.tvg-shift = tvg-shift
self.x-tvg-url = x-tvg-url
self.media-dir = media-dir
self.media-size = media-size
self.media-type = media-type
self.catchup-source = catchup-source
self.catchup-days = catchup-days
self.catchup-correction = catchup-correction
self.provider = provider
self.provider-type = provider-type
self.provider-logo = provider-logo
self.provider-countries = provider-countries
self.provider-languages = provider-languages
self.x-playlist-type = x-playlist-type
self.kodiprops = kodiprops
#todo convert json to dataclasses https://dataclass-wizard.readthedocs.io/en/latest/

View File

@@ -0,0 +1,95 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from plugin import Plugin
def run(sysARG, fitem: dict={}, nitem: dict={}):
"""
Main entry point for PseudoTV Live's functionality.
This function handles various modes of playback and interaction based on the parameters passed.
These modes include live TV, video-on-demand (VOD), DVR playback, guide display, and more.
It also processes system arguments and settings to determine the appropriate behavior.
Args:
sysARG (list): System arguments passed by the Kodi interface.
fitem (dict, optional): Dictionary containing information about the current (featured) item. Defaults to an empty dictionary.
nitem (dict, optional): Dictionary containing information about the next item. Defaults to an empty dictionary.
Behavior:
- Parses system arguments and determines the mode of operation.
- Depending on the mode, it invokes the appropriate plugin functionality (e.g., play live TV, VOD, DVR, etc.).
- Utilizes utility functions like `threadit` for threading and `PROPERTIES` for managing app states.
Supported Modes:
- 'live': Plays live TV or a playlist based on the provided parameters.
- 'vod': Plays video-on-demand content.
- 'dvr': Plays DVR recordings.
- 'resume': Resumes paused playback.
- 'broadcast': Simulates broadcast playback.
- 'radio': Plays radio streams.
- 'guide': Opens the TV guide using the PVR client.
- 'settings': Opens the settings menu.
Notifications:
- Displays notification dialogs for unsupported modes or errors.
"""
with BUILTIN.busy_dialog(), PROPERTIES.suspendActivity():
params = dict(urllib.parse.parse_qsl(sysARG[2][1:].replace('.pvr','')))
mode = (params.get("mode") or 'guide')
params['fitem'] = fitem
params['nitem'] = nitem
params['vid'] = decodeString(params.get("vid",''))
params["chid"] = (params.get("chid") or fitem.get('citem',{}).get('id'))
params['title'] = (params.get('title') or BUILTIN.getInfoLabel('label'))
params['name'] = (unquoteString(params.get("name",'')) or BUILTIN.getInfoLabel('ChannelName'))
params['isPlaylist'] = bool(SETTINGS.getSettingInt('Playback_Method'))
log("Default: run, params = %s"%(params))
if PROPERTIES.isRunning('togglePVR'): DIALOG.notificationDialog(LANGUAGE(32166))
elif mode == 'live':
if params.get('start') == '{utc}':
PROPERTIES.setPropTimer('chkPVRRefresh')
params.update({'start':0,'stop':0,'duration':0})
if params['isPlaylist']: threadit(Plugin(sysARG, sysInfo=params).playPlaylist)(params["name"],params["chid"])
elif params['vid'] : threadit(Plugin(sysARG, sysInfo=params).playTV)(params["name"],params["chid"])
elif params['isPlaylist']: threadit(Plugin(sysARG, sysInfo=params).playPlaylist)(params["name"],params["chid"])
elif params['vid'] : threadit(Plugin(sysARG, sysInfo=params).playLive)(params["name"],params["chid"],params["vid"])
else: threadit(Plugin(sysARG, sysInfo=params).playTV)(params["name"],params["chid"])
elif mode == 'vod': threadit(Plugin(sysARG, sysInfo=params).playVOD)(params["title"],params["vid"])
elif mode == 'dvr': threadit(Plugin(sysARG, sysInfo=params).playDVR)(params["title"],params["vid"])
elif mode == 'resume': threadit(Plugin(sysARG, sysInfo=params).playPaused)(params["name"],params["chid"])
elif mode == 'broadcast': threadit(Plugin(sysARG, sysInfo=params).playBroadcast)(params["name"],params["chid"],params["vid"])
elif mode == 'radio': threadit(Plugin(sysARG, sysInfo=params).playRadio)(params["name"],params["chid"],params["vid"])
elif mode == 'guide' and hasAddon(PVR_CLIENT_ID,install=True,enable=True): SETTINGS.openGuide()
elif mode == 'settings' and hasAddon(PVR_CLIENT_ID,install=True,enable=True): SETTINGS.openSettings()
else: DIALOG.notificationDialog(LANGUAGE(32000))
MONITOR().waitForAbort(float(SETTINGS.getSettingInt('RPC_Delay')/1000)) #delay to avoid thread crashes when fast channel changing.
if __name__ == '__main__':
"""
Runs the script when executed as the main module.
It decodes information about the current and next items using the `decodePlot` function
and then invokes the `run` function with the appropriate arguments.
"""
run(sys.argv, fitem=decodePlot(BUILTIN.getInfoLabel('Plot')), nitem=decodePlot(BUILTIN.getInfoLabel('NextPlot')))

View File

@@ -0,0 +1,382 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
#
# -*- coding: utf-8 -*-
from globals import *
#constants
DEFAULT_ENCODING = "utf-8"
FILE_LOCK_MAX_FILE_TIMEOUT = 10
FILE_LOCK_NAME = "pseudotv"
class FileAccess:
@staticmethod
def open(filename, mode, encoding=DEFAULT_ENCODING):
fle = 0
try: return VFSFile(filename, mode)
except UnicodeDecodeError: return FileAccess.open(filename, mode, encoding)
return fle
@staticmethod
def _getFolderPath(path):
head, tail = os.path.split(path)
last_folder = os.path.basename(head)
return os.path.join(last_folder, tail)
@staticmethod
def listdir(path):
return xbmcvfs.listdir(path)
@staticmethod
def translatePath(path):
if '@' in path: path = path.split('@')[1]
return xbmcvfs.translatePath(path)
@staticmethod
def copyFolder(src, dir, dia=None, delete=False):
log('FileAccess: copyFolder %s to %s'%(src,dir))
if not FileAccess.exists(dir): FileAccess.makedirs(dir)
if dia:
from kodi import Dialog
DIALOG = Dialog()
subs, files = FileAccess.listdir(src)
pct = 0
if dia: dia = DIALOG.progressDialog(pct, control=dia, message='%s\n%s'%(LANGUAGE(32051),src))
for fidx, file in enumerate(files):
if dia: dia = DIALOG.progressDialog(pct, control=dia, message='%s: (%s%)\n%s'%(LANGUAGE(32051),(int(fidx*100)//len(files)),FileAccess._getFolderPath(file)))
if delete: FileAccess.move(os.path.join(src, file), os.path.join(dir, file))
else: FileAccess.copy(os.path.join(src, file), os.path.join(dir, file))
for sidx, sub in enumerate(subs):
pct = int(sidx)//len(subs)
FileAccess.copyFolder(os.path.join(src, sub), os.path.join(dir, sub), dia, delete)
@staticmethod
def copy(orgfilename, newfilename):
log('FileAccess: copying %s to %s'%(orgfilename,newfilename))
dir, file = os.path.split(newfilename)
if not FileAccess.exists(dir): FileAccess.makedirs(dir)
return xbmcvfs.copy(orgfilename, newfilename)
@staticmethod
def move(orgfilename, newfilename):
log('FileAccess: moving %s to %s'%(orgfilename,newfilename))
if FileAccess.copy(orgfilename, newfilename):
return FileAccess.delete(orgfilename)
return False
@staticmethod
def delete(filename):
return xbmcvfs.delete(filename)
@staticmethod
def exists(filename):
if filename.startswith('stack://'):
try: filename = (filename.split('stack://')[1].split(' , '))[0]
except Exception as e: log('FileAccess: exists failed! %s'%(e), xbmc.LOGERROR)
try:
return xbmcvfs.exists(filename)
except UnicodeDecodeError:
return os.path.exists(xbmcvfs.translatePath(filename))
return False
@staticmethod
def openSMB(filename, mode, encoding=DEFAULT_ENCODING):
fle = 0
if os.name.lower() == 'nt':
newname = '\\\\' + filename[6:]
try: fle = codecs.open(newname, mode, encoding)
except: fle = 0
return fle
@staticmethod
def existsSMB(filename):
if os.name.lower() == 'nt':
filename = '\\\\' + filename[6:]
return FileAccess.exists(filename)
return False
@staticmethod
def rename(path, newpath):
log("FileAccess: rename %s to %s"%(path,newpath))
if not FileAccess.exists(path):
return False
try:
if xbmcvfs.rename(path, newpath):
return True
except Exception as e:
log("FileAccess: rename, failed! %s"%(e), xbmc.LOGERROR)
try:
if FileAccess.move(path, newpath):
return True
except Exception as e:
log("FileAccess: move, failed! %s"%(e), xbmc.LOGERROR)
if path[0:6].lower() == 'smb://' or newpath[0:6].lower() == 'smb://':
if os.name.lower() == 'nt':
log("FileAccess: Modifying name")
if path[0:6].lower() == 'smb://':
path = '\\\\' + path[6:]
if newpath[0:6].lower() == 'smb://':
newpath = '\\\\' + newpath[6:]
if not os.path.exist(xbmcvfs.translatePath(path)):
return False
try:
log("FileAccess: os.rename")
os.rename(xbmcvfs.translatePath(path), xbmcvfs.translatePath(newpath))
return True
except Exception as e:
log("FileAccess: os.rename, failed! %s"%(e), xbmc.LOGERROR)
try:
log("FileAccess: shutil.move")
shutil.move(xbmcvfs.translatePath(path), xbmcvfs.translatePath(newpath))
return True
except Exception as e:
log("FileAccess: shutil.move, failed! %s"%(e), xbmc.LOGERROR)
log("FileAccess: OSError")
raise OSError()
@staticmethod
def removedirs(path, force=True):
if len(path) == 0: return False
elif(xbmcvfs.exists(path)):
return True
try:
success = xbmcvfs.rmdir(dir, force=force)
if success: return True
else: raise
except:
try:
os.rmdir(xbmcvfs.translatePath(path))
if os.path.exists(xbmcvfs.translatePath(path)):
return True
except: log("FileAccess: removedirs failed!", xbmc.LOGERROR)
return False
@staticmethod
def makedirs(directory):
try:
os.makedirs(xbmcvfs.translatePath(directory))
return os.path.exists(xbmcvfs.translatePath(directory))
except:
return FileAccess._makedirs(directory)
@staticmethod
def _makedirs(path):
if len(path) == 0:
return False
if(xbmcvfs.exists(path)):
return True
success = xbmcvfs.mkdir(path)
if success == False:
if path == os.path.dirname(xbmcvfs.translatePath(path)):
return False
if FileAccess._makedirs(os.path.dirname(xbmcvfs.translatePath(path))):
return xbmcvfs.mkdir(path)
return xbmcvfs.exists(path)
class VFSFile:
monitor = MONITOR()
def __init__(self, filename, mode):
if mode == 'w':
if not FileAccess.exists(filename):
FileAccess.makedirs(os.path.split(filename)[0])
self.currentFile = xbmcvfs.File(filename, 'wb')
else:
self.currentFile = xbmcvfs.File(filename, 'r')
log("VFSFile: Opening %s"%filename, xbmc.LOGDEBUG)
if self.currentFile == None:
log("VFSFile: Couldnt open %s"%filename, xbmc.LOGERROR)
def read(self, bytes=0):
try: return self.currentFile.read(bytes)
except: return self.currentFile.readBytes(bytes)
def readBytes(self, bytes=0):
return self.currentFile.readBytes(bytes)
def write(self, data):
if isinstance(data,bytes):
data = data.decode(DEFAULT_ENCODING, 'backslashreplace')
return self.currentFile.write(data)
def close(self):
return self.currentFile.close()
def seek(self, bytes, offset=1):
return self.currentFile.seek(bytes, offset)
def size(self):
return self.currentFile.size()
def readlines(self):
return self.read().split('\n')
# return list(self.readline())
def readline(self):
for line in self.read_in_chunks():
yield line
def tell(self):
try: return self.currentFile.tell()
except: return self.currentFile.seek(0, 1)
def read_in_chunks(self, chunk_size=1024):
"""Lazy function (generator) to read a file piece by piece."""
while not self.monitor.abortRequested():
data = self.read(chunk_size)
if not data: break
yield data
class FileLock(object):
monitor = MONITOR()
# https://github.com/dmfrey/FileLock
""" A file locking mechanism that has context-manager support so
you can use it in a with statement. This should be relatively cross
compatible as it doesn't rely on msvcrt or fcntl for the locking.
"""
def __init__(self, file_name=FILE_LOCK_NAME, timeout=FILE_LOCK_MAX_FILE_TIMEOUT, delay: float=0.5):
""" Prepare the file locker. Specify the file to lock and optionally
the maximum timeout and the delay between each attempt to lock.
"""
if timeout is not None and delay is None:
raise ValueError("If timeout is not None, then delay must not be None.")
self.is_locked = False
self.file_name = file_name
self.lockpath = self.checkpath()
self.lockfile = os.path.join(self.lockpath, "%s.lock" % self.file_name)
self.timeout = timeout
self.delay = delay
def checkpath(self):
lockpath = os.path.join(REAL_SETTINGS.getSetting('User_Folder'))
if not FileAccess.exists(lockpath):
if FileAccess.makedirs(lockpath):
return lockpath
else:#fallback to local folder.
#todo log error with lock path
lockpath = os.path.join(SETTINGS_LOC,'cache')
if not FileAccess.exists(lockpath):
FileAccess.makedirs(lockpath)
return lockpath
def acquire(self):
""" Acquire the lock, if possible. If the lock is in use, it check again
every `wait` seconds. It does this until it either gets the lock or
exceeds `timeout` number of seconds, in which case it throws
an exception.
"""
start_time = time.time()
while not self.monitor.abortRequested():
if self.monitor.waitForAbort(self.delay): break
else:
try:
self.fd = FileAccess.open(self.lockfile, 'w')
self.is_locked = True #moved to ensure tag only when locked
break;
except OSError as e:
if e.errno != errno.EEXIST:
raise
if self.timeout is None:
raise FileLockException("Could not acquire lock on {}".format(self.file_name))
if (time.time() - start_time) >= self.timeout:
raise FileLockException("Timeout occured.")
def release(self):
""" Get rid of the lock by deleting the lockfile.
When working in a `with` statement, this gets automatically
called at the end.
"""
if self.is_locked:
self.fd.close()
self.is_locked = False
def __enter__(self):
""" Activated when used in the with statement.
Should automatically acquire a lock to be used in the with block.
"""
if not self.is_locked:
self.acquire()
return self
def __exit__(self, type, value, traceback):
""" Activated at the end of the with statement.
It automatically releases the lock if it isn't locked.
"""
if self.is_locked:
self.release()
def __del__(self):
""" Make sure that the FileLock instance doesn't leave a lockfile
lying around.
"""
self.release()
FileAccess.delete(self.lockfile)
class FileLockException(Exception):
pass

View File

@@ -0,0 +1,209 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from resources import Resources
#Ratings - resource only, Movie Type only any channel type
#Bumpers - plugin, path only, tv type, tv network, custom channel type
#Adverts - plugin, path only, tv type, any tv channel type
#Trailers - plug, path only, movie type, any movie channel.
class Fillers:
def __init__(self, builder, citem={}):
self.builder = builder
self.bctTypes = builder.bctTypes
self.runActions = builder.runActions
self.jsonRPC = builder.jsonRPC
self.cache = builder.jsonRPC.cache
self.citem = citem
self.resources = Resources(service=builder.service)
self.log('[%s] __init__, bctTypes = %s'%(self.citem.get('id'),builder.bctTypes))
self.fillSources(citem, builder.bctTypes)
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def fillSources(self, citem={}, bctTypes={}):
for ftype, values in list(bctTypes.items()):
if values.get('enabled',False):
self.builder.updateProgress(self.builder.pCount,message='%s %s'%(LANGUAGE(30014),ftype.title()),header='%s, %s'%(ADDON_NAME,self.builder.pMSG))
if values.get('incKODI',False): values["items"] = mergeDictLST(values.get('items',{}), self.builder.getTrailers())
for id in values["sources"].get("ids",[]):
values['items'] = mergeDictLST(values.get('items',{}),self.buildSource(ftype,id)) #parse resource packs
for path in values["sources"].get("paths",[]):
values['items'] = mergeDictLST(values.get('items',{}),self.buildSource(ftype,path)) #parse vfs paths
values['items'] = lstSetDictLst(values['items'])
self.log('[%s] fillSources, type = %s, items = %s'%(self.citem.get('id'),ftype,len(values['items'])))
@cacheit(expiration=datetime.timedelta(minutes=30),json_data=False)
def buildSource(self, ftype, path=''):
self.log('[%s] buildSource, type = %s, path = %s'%(self.citem.get('id'),ftype, path))
def _parseResource(id):
if hasAddon(id, install=True): return self.jsonRPC.walkListDirectory(os.path.join('special://home/addons/%s'%id,'resources'),exts=VIDEO_EXTS,depth=CHANNEL_LIMIT,checksum=SETTINGS.getAddonDetails(id).get('version',ADDON_VERSION),expiration=datetime.timedelta(days=MAX_GUIDEDAYS))
def _parseVFS(path):
if path.startswith('plugin://'):
if not hasAddon(path, install=True): return {}
return self.jsonRPC.walkFileDirectory(path, depth=CHANNEL_LIMIT, chkDuration=True, retItem=True)
def _parseLocal(path):
if FileAccess.exists(path): return self.jsonRPC.walkListDirectory(path,exts=VIDEO_EXTS,depth=CHANNEL_LIMIT,chkDuration=True)
def __sortItems(data, stype='folder'):
tmpDCT = {}
if data:
for path, files in list(data.items()):
if stype == 'file': key = file.split('.')[0].lower()
elif stype == 'folder': key = (os.path.basename(os.path.normpath(path)).replace('\\','/').strip('/').split('/')[-1:][0]).lower()
for file in files:
if isinstance(file,dict): [tmpDCT.setdefault(key.lower(),[]).append(file) for key in (file.get('genre',[]) or ['resources'])]
else:
dur = self.jsonRPC.getDuration(os.path.join(path,file), accurate=True)
if dur > 0: tmpDCT.setdefault(key.lower(),[]).append({'file':os.path.join(path,file),'duration':dur,'label':'%s - %s'%(path.strip('/').split('/')[-1:][0],file.split('.')[0])})
self.log('[%s] buildSource, __sortItems: stype = %s, items = %s'%(self.citem.get('id'),stype,len(tmpDCT)))
return tmpDCT
try:
if path.startswith('resource.'): return __sortItems(_parseResource(path))
elif path.startswith(tuple(VFS_TYPES+DB_TYPES)): return __sortItems(_parseVFS(path))
else: return __sortItems(_parseLocal(path))
except Exception as e: self.log("[%s] buildSource, failed! %s\n path = %s"%(self.citem.get('id'),e,path), xbmc.LOGERROR)
return {}
def convertMPAA(self, ompaa):
try:
ompaa = ompaa.upper()
mpaa = re.compile(":(.*?)/", re.IGNORECASE).search(ompaa).group(1).strip()
except: return ompaa
mpaa = mpaa.replace('TV-Y','G').replace('TV-Y7','G').replace('TV-G','G').replace('NA','NR').replace('TV-PG','PG').replace('TV-14','PG-13').replace('TV-MA','R')
return mpaa
#todo always add a bumper for pseudo/kodi (based on build ver.)
# resource.videos.bumpers.kodi
# resource.videos.bumpers.pseudotv
def getSingle(self, type, keys=['resources'], chance=False):
items = [random.choice(tmpLST) for key in keys if (tmpLST := self.bctTypes.get(type, {}).get('items', {}).get(key.lower(), []))]
if not items and chance:
items.extend(self.getSingle(type))
self.log('[%s] getSingle, type = %s, keys = %s, chance = %s, returning = %s' % (self.citem.get('id'),type, keys, chance, len(items)))
return setDictLST(items)
def getMulti(self, type, keys=['resources'], count=1, chance=False):
items = []
tmpLST = []
for key in keys:
tmpLST.extend(self.bctTypes.get(type, {}).get('items', {}).get(key.lower(), []))
if len(tmpLST) >= count:
items = random.sample(tmpLST, count)
elif tmpLST:
items = setDictLST(random.choices(tmpLST, k=count))
if len(items) < count and chance:
items.extend(self.getMulti(type, count=(count - len(items))))
self.log('[%s] getMulti, type = %s, keys = %s, count = %s, chance = %s, returning = %s' % (self.citem.get('id'),type, keys, count, chance, len(items)))
return setDictLST(items)
def injectBCTs(self, fileList):
nfileList = []
for idx, fileItem in enumerate(fileList):
if not fileItem: continue
else:
runtime = fileItem.get('duration',0)
if runtime == 0: continue
chtype = self.citem.get('type','')
chname = self.citem.get('name','')
fitem = fileItem.copy()
dbtype = fileItem.get('type','')
fmpaa = (self.convertMPAA(fileItem.get('mpaa')) or 'NR')
fcodec = (fileItem.get('streamdetails',{}).get('audio') or [{}])[0].get('codec','')
fgenre = (fileItem.get('genre') or self.citem.get('group') or '')
if isinstance(fgenre,list) and len(fgenre) > 0: fgenre = fgenre[0]
#pre roll - bumpers/ratings
if dbtype.startswith(tuple(MOVIE_TYPES)):
ftype = 'ratings'
preKeys = [fmpaa, fcodec]
elif dbtype.startswith(tuple(TV_TYPES)):
ftype = 'bumpers'
preKeys = [chname, fgenre]
else:
ftype = None
if ftype:
preFileList = []
if self.bctTypes[ftype].get('enabled',False) and chtype not in IGNORE_CHTYPE:
preFileList.extend(self.getSingle(ftype, preKeys, chanceBool(self.bctTypes[ftype].get('chance',0))))
for i, item in enumerate(setDictLST(preFileList)):
if (item.get('duration') or 0) > 0:
runtime += item.get('duration')
self.log('[%s] injectBCTs, adding pre-roll %s - %s'%(self.citem.get('id'),item.get('duration'),item.get('file')))
self.builder.updateProgress(self.builder.pCount,message='Filling Pre-Rolls %s%%'%(int(i*100//len(preFileList))),header='%s, %s'%(ADDON_NAME,self.builder.pMSG))
item.update({'title':'Pre-Roll','episodetitle':item.get('label'),'genre':['Pre-Roll'],'plot':item.get('plot',item.get('file')),'path':item.get('file')})
nfileList.append(self.builder.buildCells(self.citem,item.get('duration'),entries=1,info=item)[0])
# original media
nfileList.append(fileItem)
self.log('[%s] injectBCTs, adding media %s - %s'%(self.citem.get('id'),fileItem.get('duration'),fileItem.get('file')))
# post roll - adverts/trailers
postFileList = []
for ftype in ['adverts','trailers']:
postIgnoreTypes = {'adverts':IGNORE_CHTYPE + MOVIE_CHTYPE,'trailers':IGNORE_CHTYPE}[ftype]
postFillRuntime = diffRuntime(runtime) if self.bctTypes[ftype]['auto'] else MIN_EPG_DURATION
if self.bctTypes[ftype].get('enabled',False) and chtype not in postIgnoreTypes:
postFileList.extend(self.getMulti(ftype, [chname, fgenre], self.bctTypes[ftype]['max'] if self.bctTypes[ftype]['auto'] else self.bctTypes[ftype]['min'], chanceBool(self.bctTypes[ftype].get('chance',0))))
postAuto = (self.bctTypes['adverts']['auto'] | self.bctTypes['trailers']['auto'])
postCounter = 0
if len(postFileList) > 0:
i = 0
postFileList = randomShuffle(postFileList)
self.log('[%s] injectBCTs, post-roll current runtime %s, available runtime %s, available content %s'%(self.citem.get('id'),runtime, postFillRuntime,len(postFileList)))
while not self.builder.service.monitor.abortRequested() and postFillRuntime > 0 and len(postFileList) > 0:
if self.builder.service.monitor.waitForAbort(0.0001): break
else:
i += 1
item = postFileList.pop(0)
if (item.get('duration') or 0) == 0: continue
elif postAuto and postCounter >= len(postFileList):
self.log('[%s] injectBCTs, unused post roll runtime %s %s/%s'%(self.citem.get('id'),postFillRuntime,postCounter,len(postFileList)))
break
elif postFillRuntime >= item.get('duration'):
postFillRuntime -= item.get('duration')
self.log('[%s] injectBCTs, adding post-roll %s - %s'%(self.citem.get('id'),item.get('duration'),item.get('file')))
self.builder.updateProgress(self.builder.pCount,message='Filling Post-Rolls %s%%'%(int(i*100//len(postFileList))),header='%s, %s'%(ADDON_NAME,self.builder.pMSG))
item.update({'title':'Post-Roll','episodetitle':item.get('label'),'genre':['Post-Roll'],'plot':item.get('plot',item.get('file')),'path':item.get('file')})
nfileList.append(self.builder.buildCells(self.citem,item.get('duration'),entries=1,info=item)[0])
elif postFillRuntime < item.get('duration'):
postFileList.append(item)
postCounter += 1
self.log('[%s] injectBCTs, finished'%(self.citem.get('id')))
return nfileList

View File

@@ -0,0 +1,509 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
#
# -*- coding: utf-8 -*-
import os, sys, re, json, struct, errno, zlib
import shutil, subprocess, io, platform
import codecs, random
import uuid, base64, binascii, hashlib
import time, datetime, calendar
import heapq, requests, pyqrcode
import xml.sax.saxutils
from six.moves import urllib
from io import StringIO, BytesIO
from threading import Lock, Thread, Event, Timer, BoundedSemaphore
from threading import enumerate as thread_enumerate
from xml.dom.minidom import parse, parseString, Document
from xml.etree.ElementTree import ElementTree, Element, SubElement, XMLParser, fromstringlist, fromstring, tostring
from xml.etree.ElementTree import parse as ETparse
from typing import Dict, List, Union, Optional
from variables import *
from kodi_six import xbmc, xbmcaddon, xbmcplugin, xbmcgui, xbmcvfs
from contextlib import contextmanager, closing
from socket import gethostbyname, gethostname
from itertools import cycle, chain, zip_longest, islice
from xml.sax.saxutils import escape, unescape
from operator import itemgetter
from logger import *
from cache import Cache, cacheit
from pool import killit, timeit, poolit, executeit, timerit, threadit
from kodi import *
from fileaccess import FileAccess, FileLock
from collections import defaultdict, Counter, OrderedDict
from six.moves import urllib
from math import ceil, floor
from infotagger.listitem import ListItemInfoTag
from requests.adapters import HTTPAdapter, Retry
DIALOG = Dialog()
PROPERTIES = DIALOG.properties
SETTINGS = DIALOG.settings
LISTITEMS = DIALOG.listitems
BUILTIN = DIALOG.builtin
def slugify(s, lowercase=False):
if lowercase: s = s.lower()
s = s.strip()
s = re.sub(r'[^\w\s-]', '', s)
s = re.sub(r'[\s_-]+', '_', s)
s = re.sub(r'^-+|-+$', '', s)
return s
def validString(s):
return "".join(x for x in s if (x.isalnum() or x not in '\\/:*?"<>|'))
def stripNumber(s):
return re.sub(r'\d+','',s)
def stripRegion(s):
match = re.compile(r'(.*) \((.*)\)', re.IGNORECASE).search(s)
try: return match.group(1)
except: return s
def chanceBool(percent=25):
return random.randrange(100) <= percent
def decodePlot(text: str = '') -> dict:
plot = re.search(r'\[COLOR item=\"(.+?)\"]\[/COLOR]', text)
if plot: return loadJSON(decodeString(plot.group(1)))
return {}
def encodePlot(plot, text):
return '%s [COLOR item="%s"][/COLOR]'%(plot,encodeString(dumpJSON(text)))
def escapeString(text, table=HTML_ESCAPE):
return escape(text,table)
def unescapeString(text, table=HTML_ESCAPE):
return unescape(text,{v:k for k, v in list(table.items())})
def getJSON(file):
data = {}
try:
fle = FileAccess.open(file,'r')
data = loadJSON(fle.read())
except Exception as e: log('Globals: getJSON failed! %s\nfile = %s'%(e,file), xbmc.LOGERROR)
fle.close()
return data
def setJSON(file, data):
with FileLock():
fle = FileAccess.open(file, 'w')
fle.write(dumpJSON(data, idnt=4, sortkey=False))
fle.close()
return True
def requestURL(url, params={}, payload={}, header=HEADER, timeout=FIFTEEN, json_data=False, cache=None, checksum=ADDON_VERSION, life=datetime.timedelta(minutes=15)):
def __error(json_data):
return {} if json_data else ""
def __getCache(key,json_data,cache,checksum):
return (cache.get('requestURL.%s'%(key), checksum, json_data) or __error(json_data))
def __setCache(key,results,json_data,cache,checksum,life):
return cache.set('requestURL.%s'%(key), results, checksum, life, json_data)
complete = False
cacheKey = '.'.join([url,dumpJSON(params),dumpJSON(payload),dumpJSON(header)])
session = requests.Session()
retries = Retry(total=3, backoff_factor=1, status_forcelist=[500, 502, 503, 504])
adapter = HTTPAdapter(max_retries=retries)
session.mount("http://", adapter)
session.mount("https://", adapter)
try:
headers = HEADER.copy()
headers.update(header)
if payload: response = session.post(url, data=dumpJSON(payload), headers=headers, timeout=timeout)
else: response = session.get(url, params=params, headers=headers, timeout=timeout)
response.raise_for_status() # Raise an exception for HTTP errors
log("Globals: requestURL, url = %s, status = %s"%(url,response.status_code))
complete = True
if json_data: results = response.json()
else: results = response.content
if results and cache: return __setCache(cacheKey,results,json_data,cache,checksum,life)
else: return results
except requests.exceptions.ConnectionError as e:
log("Globals: requestURL, failed! Error connecting to the server: %s"%('Returning cache' if cache else 'No Response'))
return __getCache(cacheKey,json_data,cache,checksum) if cache else __error(json_data)
except requests.exceptions.HTTPError as e:
log("Globals: requestURL, failed! HTTP error occurred: %s"%('Returning cache' if cache else 'No Response'))
return __getCache(cacheKey,json_data,cache,checksum) if cache else __error(json_data)
except Exception as e:
log("Globals: requestURL, failed! An error occurred: %s"%(e), xbmc.LOGERROR)
return __error(json_data)
finally:
if not complete and payload:
queueURL({"url":url, "params":params, "payload":payload, "header":header, "timeout":timeout, "json_data":json_data, "cache":cache, "checksum":checksum, "life":life}) #retry post
def queueURL(param):
queuePool = (SETTINGS.getCacheSetting('queueURL', json_data=True) or {})
params = queuePool.setdefault('params',[])
params.append(param)
queuePool['params'] = setDictLST(params)
log("Globals: queueURL, saving = %s\n%s"%(len(queuePool['params']),param))
SETTINGS.setCacheSetting('queueURL', queuePool, json_data=True)
def setURL(url, file):
try:
contents = requestURL(url)
fle = FileAccess.open(file, 'w')
fle.write(contents)
fle.close()
return FileAccess.exists(file)
except Exception as e: log('Globals: setURL failed! %s\nurl = %s'%(e,url), xbmc.LOGERROR)
def diffLSTDICT(old, new):
set1 = {dumpJSON(d, sortkey=True) for d in old}
set2 = {dumpJSON(d, sortkey=True) for d in new}
return {"added": [loadJSON(s) for s in set2 - set1], "removed": [loadJSON(s) for s in set1 - set2]}
def getChannelID(name, path, number):
if isinstance(path, list): path = '|'.join(path)
tmpid = '%s.%s.%s.%s'%(number, name, hashlib.md5(path.encode(DEFAULT_ENCODING)),SETTINGS.getMYUUID())
return '%s@%s'%((binascii.hexlify(tmpid.encode(DEFAULT_ENCODING))[:32]).decode(DEFAULT_ENCODING),slugify(ADDON_NAME))
def getRecordID(name, path, number):
if isinstance(path, list): path = '|'.join(path)
tmpid = '%s.%s.%s.%s'%(number, name, hashlib.md5(path.encode(DEFAULT_ENCODING)),SETTINGS.getMYUUID())
return '%s@%s'%((binascii.hexlify(tmpid.encode(DEFAULT_ENCODING))[:16]).decode(DEFAULT_ENCODING),slugify(ADDON_NAME))
def splitYear(label):
try:
match = re.compile(r'(.*) \((.*)\)', re.IGNORECASE).search(label)
if match and match.group(2):
label, year = match.groups()
if year.isdigit():
return label, int(year)
except: pass
return label, None
def getChannelSuffix(name, type):
name = validString(name)
if type == "TV Genres" and not LANGUAGE(32014).lower() in name.lower(): suffix = LANGUAGE(32014) #TV
elif type == "Movie Genres" and not LANGUAGE(32015).lower() in name.lower(): suffix = LANGUAGE(32015) #Movies
elif type == "Mixed Genres" and not LANGUAGE(32010).lower() in name.lower(): suffix = LANGUAGE(32010) #Mixed
elif type == "Music Genres" and not LANGUAGE(32016).lower() in name.lower(): suffix = LANGUAGE(32016) #Music
else: return name
return '%s %s'%(name,suffix)
def cleanChannelSuffix(name, type):
if type == "TV Genres" : name = name.split(' %s'%LANGUAGE(32014))[0]#TV
elif type == "Movie Genres" : name = name.split(' %s'%LANGUAGE(32015))[0]#Movies
elif type == "Mixed Genres" : name = name.split(' %s'%LANGUAGE(32010))[0]#Mixed
elif type == "Music Genres" : name = name.split(' %s'%LANGUAGE(32016))[0]#Music
return name
def getLabel(item, addYear=False):
label = (item.get('name') or item.get('label') or item.get('showtitle') or item.get('title'))
if not label: return ''
label, year = splitYear(label)
year = (item.get('year') or year)
if year and addYear: return '%s (%s)'%(label, year)
return label
def hasFile(file):
if not file.startswith(tuple(VFS_TYPES + WEB_TYPES)): state = FileAccess.exists(file)
elif file.startswith('plugin://'): state = hasAddon(file)
else: state = True
log("Globals: hasFile, file = %s (%s)"%(file,state))
return state
def hasAddon(id, install=False, enable=False, force=False, notify=False):
if '://' in id: id = getIDbyPath(id)
if BUILTIN.getInfoBool('HasAddon(%s)'%(id),'System'):
if BUILTIN.getInfoBool('AddonIsEnabled(%s)'%(id),'System'): return True
elif enable:
if not force:
if not DIALOG.yesnoDialog(message=LANGUAGE(32156)%(id)): return False
return BUILTIN.executebuiltin('EnableAddon(%s)'%(id),wait=True)
elif install: return BUILTIN.executebuiltin('InstallAddon(%s)'%(id),wait=True)
if notify: DIALOG.notificationDialog(LANGUAGE(32034)%(id))
return False
def diffRuntime(dur, roundto=15):
def ceil_dt(dt, delta):
return dt + (datetime.datetime.min - dt) % delta
now = datetime.datetime.fromtimestamp(dur)
return (ceil_dt(now, datetime.timedelta(minutes=roundto)) - now).total_seconds()
def roundTimeDown(dt, offset=30): # round the given time down to the nearest
n = datetime.datetime.fromtimestamp(dt)
delta = datetime.timedelta(minutes=offset)
if n.minute > (offset-1): n = n.replace(minute=offset, second=0, microsecond=0)
else: n = n.replace(minute=0, second=0, microsecond=0)
return time.mktime(n.timetuple())
def roundTimeUp(dt=None, roundTo=60):
if dt == None : dt = datetime.datetime.now()
seconds = (dt.replace(tzinfo=None) - dt.min).seconds
rounding = (seconds+roundTo/2) // roundTo * roundTo
return dt + datetime.timedelta(0,rounding-seconds,-dt.microsecond)
def strpTime(datestring, format=DTJSONFORMAT): #convert pvr infolabel datetime string to datetime obj, thread safe!
try: return datetime.datetime.strptime(datestring, format)
except TypeError: return datetime.datetime.fromtimestamp(time.mktime(time.strptime(datestring, format)))
except: return ''
def epochTime(timestamp, tz=True): #convert pvr json datetime string to datetime obj
if tz: timestamp -= getTimeoffset()
return datetime.datetime.fromtimestamp(timestamp)
def getTimeoffset():
return (int((datetime.datetime.now() - datetime.datetime.utcnow()).days * 86400 + round((datetime.datetime.now() - datetime.datetime.utcnow()).seconds, -1)))
def getUTCstamp():
return time.time() - getTimeoffset()
def getGMTstamp():
return time.time()
def randomShuffle(items=[]):
if len(items) > 0:
#reseed random for a "greater sudo random"
random.seed(random.randint(0,999999999999))
random.shuffle(items)
return items
def isStack(path): #is path a stack
return path.startswith('stack://')
def splitStacks(path): #split stack path for indv. files.
if not isStack(path): return [path]
return [_f for _f in ((path.split('stack://')[1]).split(' , ')) if _f]
def escapeDirJSON(path):
mydir = path
if (mydir.find(":")): mydir = mydir.replace("\\", "\\\\")
return mydir
def KODI_LIVETV_SETTINGS(): #recommended Kodi LiveTV settings
return {'pvrmanager.preselectplayingchannel' :'true',
'pvrmanager.syncchannelgroups' :'true',
'pvrmanager.backendchannelorder' :'true',
'pvrmanager.usebackendchannelnumbers':'true',
'pvrplayback.autoplaynextprogramme' :'true',
# 'pvrmenu.iconpath':'',
# 'pvrplayback.switchtofullscreenchanneltypes':1,
# 'pvrplayback.confirmchannelswitch':'true',
# 'epg.selectaction':2,
# 'epg.epgupdate':120,
'pvrmanager.startgroupchannelnumbersfromone':'false'}
def togglePVR(state=True, reverse=False, wait=FIFTEEN):
if SETTINGS.getSettingBool('Enable_PVR_RELOAD'):
isEnabled = BUILTIN.getInfoBool('AddonIsEnabled(%s)'%(PVR_CLIENT_ID),'System')
if (state and isEnabled) or (not state and not isEnabled): return
elif not PROPERTIES.isRunning('togglePVR'):
with PROPERTIES.chkRunning('togglePVR'):
BUILTIN.executebuiltin("Dialog.Close(all)")
log('globals: togglePVR, state = %s, reverse = %s, wait = %s'%(state,reverse,wait))
BUILTIN.executeJSONRPC('{"jsonrpc":"2.0","method":"Addons.SetAddonEnabled","params":{"addonid":"%s","enabled":%s}, "id": 1}'%(PVR_CLIENT_ID,str(state).lower()))
if reverse:
with BUILTIN.busy_dialog():
MONITOR().waitForAbort(1.0)
timerit(togglePVR)(wait,[not bool(state)])
DIALOG.notificationWait('%s: %s'%(PVR_CLIENT_NAME,LANGUAGE(32125)),wait=wait)
else: DIALOG.notificationWait(LANGUAGE(30023)%(PVR_CLIENT_NAME))
def isRadio(item):
if item.get('radio',False) or item.get('type') == "Music Genres": return True
for path in item.get('path',[item.get('file','')]):
if path.lower().startswith(('musicdb://','special://profile/playlists/music/','special://musicplaylists/')): return True
return False
def isMixed_XSP(item):
for path in item.get('path',[item.get('file','')]):
if path.lower().startswith('special://profile/playlists/mixed/'): return True
return False
def cleanLabel(text):
text = re.sub(r'\[COLOR=(.+?)\]', '', text)
text = re.sub(r'\[/COLOR\]', '', text)
text = text.replace("[B]",'').replace("[/B]",'')
text = text.replace("[I]",'').replace("[/I]",'')
return text.replace(":",'')
def cleanImage(image=LOGO):
if not image: image = LOGO
if not image.startswith(('image://','resource://','special://','smb://','nfs://','https://','http://')):
realPath = FileAccess.translatePath('special://home/addons/')
if image.startswith(realPath):# convert real path. to vfs
image = image.replace(realPath,'special://home/addons/').replace('\\','/')
elif image.startswith(realPath.replace('\\','/')):
image = image.replace(realPath.replace('\\','/'),'special://home/addons/').replace('\\','/')
return image
def cleanGroups(citem, enableGrouping=SETTINGS.getSettingBool('Enable_Grouping')):
if not enableGrouping:
citem['group'] = [ADDON_NAME]
else:
citem['group'].append(ADDON_NAME)
if citem.get('favorite',False) and not LANGUAGE(32019) in citem['group']:
citem['group'].append(LANGUAGE(32019))
elif not citem.get('favorite',False) and LANGUAGE(32019) in citem['group']:
citem['group'].remove(LANGUAGE(32019))
return sorted(set(citem['group']))
def cleanMPAA(mpaa):
orgMPA = mpaa
mpaa = mpaa.lower()
if ':' in mpaa: mpaa = re.split(':',mpaa)[1] #todo prop. regex
if 'rated ' in mpaa: mpaa = re.split('rated ',mpaa)[1] #todo prop. regex
#todo regex, detect other region rating formats
# re.compile(':(.*)', re.IGNORECASE).search(text))
text = mpaa.upper()
try:
text = re.sub('/ US', '' , text)
text = re.sub('Rated ', '', text)
mpaa = text.strip()
except:
mpaa = mpaa.strip()
return mpaa
def getIDbyPath(url):
try:
if url.startswith('special://'): return re.compile('special://home/addons/(.*?)/resources', re.IGNORECASE).search(url).group(1)
elif url.startswith('plugin://'): return re.compile('plugin://(.*?)/', re.IGNORECASE).search(url).group(1)
except Exception as e: log('Globals: getIDbyPath failed! url = %s, %s'%(url,e), xbmc.LOGERROR)
return url
def combineDicts(dict1={}, dict2={}):
for k,v in list(dict1.items()):
if dict2.get(k): k = dict2.pop(k)
dict1.update(dict2)
return dict1
def mergeDictLST(dict1={},dict2={}):
for k, v in list(dict2.items()):
dict1.setdefault(k,[]).extend(v)
setDictLST()
return dict1
def lstSetDictLst(lst=[]):
items = dict()
for key, dictlst in list(lst.items()):
if isinstance(dictlst, list): dictlst = setDictLST(dictlst)
items[key] = dictlst
return items
def compareDict(dict1,dict2,sortKey):
a = sorted(dict1, key=itemgetter(sortKey))
b = sorted(dict2, key=itemgetter(sortKey))
return a == b
def subZoom(number,percentage,multi=100):
return round(number * (percentage*multi) / 100)
def addZoom(number,percentage,multi=100):
return round((number - (number * (percentage*multi) / 100)) + number)
def frange(start,stop,inc):
return [x/10.0 for x in range(start,stop,inc)]
def timeString2Seconds(string): #hh:mm:ss
try: return int(sum(x*y for x, y in zip(list(map(float, string.split(':')[::-1])), (1, 60, 3600, 86400))))
except: return -1
def chunkLst(lst, n):
for i in range(0, len(lst), n):
yield lst[i:i + n]
def chunkDict(items, n):
it = iter(items)
for i in range(0, len(items), n):
yield {k:items[k] for k in islice(it, n)}
def roundupDIV(p, q):
try:
d, r = divmod(p, q)
if r: d += 1
return d
except ZeroDivisionError:
return 1
def interleave(seqs, sets=1, repeats=False):
#evenly interleave multi-lists of different sizes, while preserving seq order and by sets of x
# In [[1,2,3,4],['a','b','c'],['A','B','C','D','E']]
# repeats = False
# Out sets=0 [1, 2, 3, 4, 'a', 'b', 'c', 'A', 'B', 'C', 'D', 'E']
# Out sets=1 [1, 'a', 'A', 2, 'b', 'B', 3, 'c', 'C', 4, 'D', 'E']
# Out sets=2 [1, 2, 'a', 'b', 'A', 'B', 3, 4, 'c', 'C', 'D', 'E']
# repeats = True
# Out sets=0 [1, 2, 3, 4, 'a', 'b', 'c', 'A', 'B', 'C', 'D', 'E']
# Out sets=1 [1, 'a', 'A', 2, 'b', 'B', 3, 'c', 'C', 4, 'a', 'D', 1, 'b', 'E']
# Out sets=2 [1, 2, 'a', 'b', 'A', 'B', 3, 4, 'c', 'a', 'C', 'D', 1, 2, 'b', 'c', 'E', 'A']
if sets > 0:
# if repeats:
# # Create cyclical iterators for each list
# cyclical_iterators = [cycle(lst) for lst in seqs]
# interleaved = []
# # Determine the length of the longest list
# max_len = max(len(lst) for lst in seqs)
# # Calculate the number of blocks needed
# num_blocks = (max_len + sets - 1) // sets
# # Interleave in blocks
# for i in range(num_blocks):
# for iterator in cyclical_iterators:
# # Use islice to take a block of elements from the current iterator
# block = list(islice(iterator, sets))
# interleaved.extend(block)
# return interleaved
# else:
seqs = [list(zip_longest(*[iter(seqs)] * sets, fillvalue=None)) for seqs in seqs]
return list([_f for _f in sum([_f for _f in chain.from_iterable(zip_longest(*seqs)) if _f], ()) if _f])
else: return list(chain.from_iterable(seqs))
def percentDiff(org, new):
try: return (abs(float(org) - float(new)) / float(new)) * 100.0
except ZeroDivisionError: return -1
def pagination(list, end):
for start in range(0, len(list), end):
yield seq[start:start+end]
def isCenterlized():
default = 'special://profile/addon_data/plugin.video.pseudotv.live/cache'
if REAL_SETTINGS.getSetting('User_Folder') == default:
return False
return True
def isFiller(item={}):
for genre in item.get('genre',[]):
if genre.lower() in ['pre-roll','post-roll']: return True
return False
def isShort(item={}, minDuration=SETTINGS.getSettingInt('Seek_Tolerance')):
if item.get('duration', minDuration) < minDuration: return True
else: return False
def isEnding(progress=100):
if progress >= SETTINGS.getSettingInt('Seek_Threshold'): return True
else: return False
def chkLogo(old, new=LOGO):
if new.endswith('wlogo.png') and not old.endswith('wlogo.png'): return old
return new

View File

@@ -0,0 +1,176 @@
# -*- coding: utf-8 -*-
"""
JSON 2 HTML Converter
=====================
(c) Varun Malhotra 2013-2024
Source Code: https://github.com/softvar/json2html
Contributors:
-------------
1. Michel Müller (@muellermichel), https://github.com/softvar/json2html/pull/2
2. Daniel Lekic (@lekic), https://github.com/softvar/json2html/pull/17
LICENSE: MIT
--------
"""
import sys
from collections import OrderedDict
import json as json_parser
if sys.version_info[:2] < (3, 0):
from cgi import escape as html_escape
text = unicode
text_types = (unicode, str)
else:
from html import escape as html_escape
text = str
text_types = (str,)
class Json2Html:
def convert(self, json="", table_attributes='border="1"', clubbing=True, encode=False, escape=True):
"""
Convert JSON to HTML Table format
"""
# table attributes such as class, id, data-attr-*, etc.
# eg: table_attributes = 'class = "table table-bordered sortable"'
self.table_init_markup = "<table %s>" % table_attributes
self.clubbing = clubbing
self.escape = escape
json_input = None
if not json:
json_input = {}
elif type(json) in text_types:
try:
json_input = json_parser.loads(json, object_pairs_hook=OrderedDict)
except ValueError as e:
#so the string passed here is actually not a json string
# - let's analyze whether we want to pass on the error or use the string as-is as a text node
if u"Expecting property name" in text(e):
#if this specific json loads error is raised, then the user probably actually wanted to pass json, but made a mistake
raise e
json_input = json
else:
json_input = json
converted = self.convert_json_node(json_input)
if encode:
return converted.encode('ascii', 'xmlcharrefreplace')
return converted
def column_headers_from_list_of_dicts(self, json_input):
"""
This method is required to implement clubbing.
It tries to come up with column headers for your input
"""
if not json_input \
or not hasattr(json_input, '__getitem__') \
or not hasattr(json_input[0], 'keys'):
return None
column_headers = list(json_input[0].keys())
for entry in json_input:
if not hasattr(entry, 'keys') \
or not hasattr(entry, '__iter__') \
or len(list(entry.keys())) != len(column_headers):
return None
for header in column_headers:
if header not in entry:
return None
return column_headers
def convert_json_node(self, json_input):
"""
Dispatch JSON input according to the outermost type and process it
to generate the super awesome HTML format.
We try to adhere to duck typing such that users can just pass all kinds
of funky objects to json2html that *behave* like dicts and lists and other
basic JSON types.
"""
if type(json_input) in text_types:
if self.escape:
return html_escape(text(json_input))
else:
return text(json_input)
if hasattr(json_input, 'items'):
return self.convert_object(json_input)
if hasattr(json_input, '__iter__') and hasattr(json_input, '__getitem__'):
return self.convert_list(json_input)
return text(json_input)
def convert_list(self, list_input):
"""
Iterate over the JSON list and process it
to generate either an HTML table or a HTML list, depending on what's inside.
If suppose some key has array of objects and all the keys are same,
instead of creating a new row for each such entry,
club such values, thus it makes more sense and more readable table.
@example:
jsonObject = {
"sampleData": [
{"a":1, "b":2, "c":3},
{"a":5, "b":6, "c":7}
]
}
OUTPUT:
_____________________________
| | | | |
| | a | c | b |
| sampleData |---|---|---|
| | 1 | 3 | 2 |
| | 5 | 7 | 6 |
-----------------------------
@contributed by: @muellermichel
"""
if not list_input:
return ""
converted_output = ""
column_headers = None
if self.clubbing:
column_headers = self.column_headers_from_list_of_dicts(list_input)
if column_headers is not None:
converted_output += self.table_init_markup
converted_output += '<thead>'
converted_output += '<tr><th>' + '</th><th>'.join(column_headers) + '</th></tr>'
converted_output += '</thead>'
converted_output += '<tbody>'
for list_entry in list_input:
converted_output += '<tr><td>'
converted_output += '</td><td>'.join([self.convert_json_node(list_entry[column_header]) for column_header in
column_headers])
converted_output += '</td></tr>'
converted_output += '</tbody>'
converted_output += '</table>'
return converted_output
#so you don't want or need clubbing eh? This makes @muellermichel very sad... ;(
#alright, let's fall back to a basic list here...
converted_output = '<ul><li>'
converted_output += '</li><li>'.join([self.convert_json_node(child) for child in list_input])
converted_output += '</li></ul>'
return converted_output
def convert_object(self, json_input):
"""
Iterate over the JSON object and process it
to generate the super awesome HTML Table format
"""
if not json_input:
return "" #avoid empty tables
converted_output = self.table_init_markup + "<tr>"
converted_output += "</tr><tr>".join([
"<th>%s</th><td>%s</td>" %(
self.convert_json_node(k),
self.convert_json_node(v)
)
for k, v in list(json_input.items())
])
converted_output += '</tr></table>'
return converted_output
json2html = Json2Html()

View File

@@ -0,0 +1,668 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from videoparser import VideoParser
class Service:
player = PLAYER()
monitor = MONITOR()
def _interrupt(self) -> bool:
return PROPERTIES.isPendingInterrupt()
def _suspend(self) -> bool:
return PROPERTIES.isPendingSuspend()
class JSONRPC:
def __init__(self, service=None):
if service is None: service = Service()
self.service = service
self.cache = SETTINGS.cacheDB
self.videoParser = VideoParser()
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s' % (self.__class__.__name__, msg), level)
def sendJSON(self, param, timeout=-1):
command = param
command["jsonrpc"] = "2.0"
command["id"] = ADDON_ID
self.log('sendJSON, timeout = %s, command = %s'%(timeout,dumpJSON(command)))
if timeout > 0: response = loadJSON((killit(BUILTIN.executeJSONRPC)(timeout,dumpJSON(command))) or {'error':{'message':'JSONRPC timed out!'}})
else: response = loadJSON(BUILTIN.executeJSONRPC(dumpJSON(command)))
if response.get('error'):
self.log('sendJSON, failed! error = %s\n%s'%(dumpJSON(response.get('error')),command), xbmc.LOGWARNING)
response.setdefault('result',{})['error'] = response.pop('error')
#throttle calls, low power devices suffer segfault during rpc flood
self.service.monitor.waitForAbort(float(SETTINGS.getSettingInt('RPC_Delay')/1000))
return response
def queueJSON(self, param):
queuePool = (SETTINGS.getCacheSetting('queueJSON', json_data=True) or {})
params = queuePool.setdefault('params',[])
params.append(param)
queuePool['params'] = sorted(setDictLST(params), key=lambda d: d.get('params',{}).get('setting',''))
queuePool['params'] = sorted(setDictLST(params), key=lambda d: d.get('params',{}).get('playcount',0))
queuePool['params'].reverse() #prioritize setsetting,playcount rollback over duration amendments.
self.log("queueJSON, saving = %s\n%s"%(len(queuePool['params']),param))
SETTINGS.setCacheSetting('queueJSON', queuePool, json_data=True)
def cacheJSON(self, param, life=datetime.timedelta(minutes=15), checksum=ADDON_VERSION, timeout=-1):
cacheName = 'cacheJSON.%s'%(getMD5(dumpJSON(param)))
cacheResponse = self.cache.get(cacheName, checksum=checksum, json_data=True)
if not cacheResponse:
cacheResponse = self.sendJSON(param, timeout)
if cacheResponse.get('result',{}): self.cache.set(cacheName, cacheResponse, checksum=checksum, expiration=life, json_data=True)
return cacheResponse
def walkFileDirectory(self, path, media='video', depth=5, chkDuration=False, retItem=False, checksum=ADDON_VERSION, expiration=datetime.timedelta(minutes=15)):
walk = dict()
self.log('walkFileDirectory, walking %s, depth = %s'%(path,depth))
items = self.getDirectory({"directory":path,"media":media},True,checksum,expiration).get('files',[])
for idx, item in enumerate(items):
if item.get('filetype') == 'file':
if chkDuration:
item['duration'] = self.getDuration(item.get('file'),item, accurate=bool(SETTINGS.getSettingInt('Duration_Type')))
if item['duration'] == 0: continue
walk.setdefault(path,[]).append(item if retItem else item.get('file'))
elif item.get('filetype') == 'directory' and depth > 0:
depth -= 1
walk.update(self.walkFileDirectory(item.get('file'), media, depth, chkDuration, retItem, checksum, expiration))
return walk
def walkListDirectory(self, path, exts='', depth=5, chkDuration=False, appendPath=False, checksum=ADDON_VERSION, expiration=datetime.timedelta(minutes=15)):
def _chkfile(path, f):
if exts and not f.lower().endswith(tuple(exts)): return
if chkDuration:
dur = self.getDuration(os.path.join(path,f), accurate=bool(SETTINGS.getSettingInt('Duration_Type')))
if dur == 0: return
return {True:os.path.join(path,f).replace('\\','/'),False:f}[appendPath]
def _parseXBT(resource):
self.log('walkListDirectory, parsing XBT = %s'%(resource))
walk.setdefault(resource,[]).extend(self.getListDirectory(resource,checksum,expiration)[1])
return walk
walk = dict()
path = path.replace('\\','/')
subs, files = self.getListDirectory(path,checksum,expiration)
if len(files) > 0 and TEXTURES in files: return _parseXBT(re.sub('/resources','',path).replace('special://home/addons/','resource://'))
nfiles = [_f for _f in [_chkfile(path, file) for file in files] if _f]
self.log('walkListDirectory, walking %s, found = %s, appended = %s, depth = %s, ext = %s'%(path,len(files),len(nfiles),depth,exts))
walk.setdefault(path,[]).extend(nfiles)
for sub in subs:
if depth == 0: break
depth -= 1
walk.update(self.walkListDirectory(os.path.join(path,sub), exts, depth, chkDuration, appendPath, checksum, expiration))
return walk
def getListDirectory(self, path, checksum=ADDON_VERSION, expiration=datetime.timedelta(minutes=15)):
cacheName = 'getListDirectory.%s'%(getMD5(path))
results = self.cache.get(cacheName, checksum)
if not results:
try:
results = self.cache.set(cacheName, FileAccess.listdir(path), checksum, expiration)
self.log('getListDirectory path = %s, checksum = %s'%(path, checksum))
except Exception as e:
self.log("getListDirectory, failed! %s\npath = %s"%(e,path), xbmc.LOGERROR)
results = [],[]
self.log('getListDirectory return dirs = %s, files = %s\n%s'%(len(results[0]), len(results[1]),path))
return results
def getIntrospect(self, id):
param = {"method":"JSONRPC.Introspect","params":{"filter":{"id":id,"type":"method"}}}
return self.cacheJSON(param,datetime.timedelta(days=28),BUILTIN.getInfoLabel('BuildVersion','System')).get('result',{})
def getEnums(self, id, type='', key='enums'):
self.log('getEnums id = %s, type = %s, key = %s' % (id, type, key))
param = {"method":"JSONRPC.Introspect","params":{"getmetadata":True,"filterbytransport":True,"filter":{"getreferences":False,"id":id,"type":"type"}}}
json_response = self.cacheJSON(param,datetime.timedelta(days=28),BUILTIN.getInfoLabel('BuildVersion','System')).get('result',{}).get('types',{}).get(id,{})
return (json_response.get('properties',{}).get(type,{}).get(key) or json_response.get(type,{}).get(key) or json_response.get(key,[]))
def notifyAll(self, message, data, sender=ADDON_ID):
param = {"method":"JSONRPC.NotifyAll","params":{"sender":sender,"message":message,"data":[data]}}
return self.sendJSON(param).get('result') == 'OK'
def playerOpen(self, params={}):
param = {"method":"Player.Open","params":params}
return self.sendJSON(param).get('result') == 'OK'
def getSetting(self, category, section, cache=False):
param = {"method":"Settings.GetSettings","params":{"filter":{"category":category,"section":section}}}
if cache: return self.cacheJSON(param).get('result',{}).get('settings',[])
else: return self.sendJSON(param).get('result', {}).get('settings',[])
def getSettingValue(self, key, default='', cache=False):
param = {"method":"Settings.GetSettingValue","params":{"setting":key}}
if cache: return (self.cacheJSON(param).get('result',{}).get('value') or default)
else: return (self.sendJSON(param).get('result',{}).get('value') or default)
def setSettingValue(self, key, value, queue=False):
param = {"method":"Settings.SetSettingValue","params":{"setting":key,"value":value}}
if queue: self.queueJSON(param)
else: self.sendJSON(param)
def getSources(self, media='video', cache=True):
param = {"method":"Files.GetSources","params":{"media":media}}
if cache: return self.cacheJSON(param).get('result', {}).get('sources', [])
else: return self.sendJSON(param).get('result', {}).get('sources', [])
def getAddonDetails(self, addonid=ADDON_ID, cache=True):
param = {"method":"Addons.GetAddonDetails","params":{"addonid":addonid,"properties":self.getEnums("Addon.Fields", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('addon', {})
else: return self.sendJSON(param).get('result', {}).get('addon', {})
def getAddons(self, param={"content":"video","enabled":True,"installed":True}, cache=True):
param["properties"] = self.getEnums("Addon.Fields", type='items')
param = {"method":"Addons.GetAddons","params":param}
if cache: return self.cacheJSON(param).get('result', {}).get('addons', [])
else: return self.sendJSON(param).get('result', {}).get('addons', [])
def getSongs(self, cache=True):
param = {"method":"AudioLibrary.GetSongs","params":{"properties":self.getEnums("Audio.Fields.Song", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('songs', [])
else: return self.sendJSON(param).get('result', {}).get('songs', [])
def getArtists(self, cache=True):
param = {"method":"AudioLibrary.GetArtists","params":{"properties":self.getEnums("Audio.Fields.Artist", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('artists', [])
else: return self.sendJSON(param).get('result', {}).get('artists', [])
def getAlbums(self, cache=True):
param = {"method":"AudioLibrary.GetAlbums","params":{"properties":self.getEnums("Audio.Fields.Album", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('albums', [])
else: return self.sendJSON(param).get('result', {}).get('albums', [])
def getEpisodes(self, cache=True):
param = {"method":"VideoLibrary.GetEpisodes","params":{"properties":self.getEnums("Video.Fields.Episode", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('episodes', [])
else: return self.sendJSON(param).get('result', {}).get('episodes', [])
def getTVshows(self, cache=True):
param = {"method":"VideoLibrary.GetTVShows","params":{"properties":self.getEnums("Video.Fields.TVShow", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('tvshows', [])
else: return self.sendJSON(param).get('result', {}).get('tvshows', [])
def getMovies(self, cache=True):
param = {"method":"VideoLibrary.GetMovies","params":{"properties":self.getEnums("Video.Fields.Movie", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('movies', [])
else: return self.sendJSON(param).get('result', {}).get('movies', [])
def getVideoGenres(self, type="movie", cache=True): #type = "movie"/"tvshow"
param = {"method":"VideoLibrary.GetGenres","params":{"type":type,"properties":self.getEnums("Library.Fields.Genre", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('genres', [])
else: return self.sendJSON(param).get('result', {}).get('genres', [])
def getMusicGenres(self, cache=True):
param = {"method":"AudioLibrary.GetGenres","params":{"properties":self.getEnums("Library.Fields.Genre", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('genres', [])
else: return self.sendJSON(param).get('result', {}).get('genres', [])
def getDirectory(self, param={}, cache=True, checksum=ADDON_VERSION, expiration=datetime.timedelta(minutes=15), timeout=-1):
param["properties"] = self.getEnums("List.Fields.Files", type='items')
param = {"method":"Files.GetDirectory","params":param}
if cache: return self.cacheJSON(param, expiration, checksum, timeout).get('result', {})
else: return self.sendJSON(param, timeout).get('result', {})
def getLibrary(self, method, param={}, cache=True):
param = {"method":method,"params":param}
if cache: return self.cacheJSON(param).get('result', {})
else: return self.sendJSON(param).get('result', {})
def getStreamDetails(self, path, media='video'):
if isStack(path): path = splitStacks(path)[0]
param = {"method":"Files.GetFileDetails","params":{"file":path,"media":media,"properties":["streamdetails"]}}
return self.cacheJSON(param, life=datetime.timedelta(days=MAX_GUIDEDAYS), checksum=getMD5(path)).get('result',{}).get('filedetails',{}).get('streamdetails',{})
def getFileDetails(self, file, media='video', properties=["duration","runtime"]):
return self.cacheJSON({"method":"Files.GetFileDetails","params":{"file":file,"media":media,"properties":properties}})
def getViewMode(self):
default = {"nonlinearstretch":False,"pixelratio":1,"verticalshift":0,"viewmode":"custom","zoom": 1.0}
return self.cacheJSON({"method":"Player.GetViewMode","params":{}},datetime.timedelta(seconds=FIFTEEN)).get('result',default)
def setViewMode(self, params={}):
return self.sendJSON({"method":"Player.SetViewMode","params":params})
def getPlayerItem(self, playlist=False):
self.log('getPlayerItem, playlist = %s' % (playlist))
if playlist: param = {"method":"Playlist.GetItems","params":{"playlistid":self.getActivePlaylist(),"properties":self.getEnums("List.Fields.All", type='items')}}
else: param = {"method":"Player.GetItem" ,"params":{"playerid":self.getActivePlayer() ,"properties":self.getEnums("List.Fields.All", type='items')}}
result = self.sendJSON(param).get('result', {})
return (result.get('item', {}) or result.get('items', []))
def getPVRChannels(self, radio=False):
param = {"method":"PVR.GetChannels","params":{"channelgroupid":{True:'allradio',False:'alltv'}[radio],"properties":self.getEnums("PVR.Fields.Channel", type='items')}}
return self.sendJSON(param).get('result', {}).get('channels', [])
def getPVRChannelsDetails(self, id):
param = {"method":"PVR.GetChannelDetails","params":{"channelid":id,"properties":self.getEnums("PVR.Fields.Channel", type='items')}}
return self.sendJSON(param).get('result', {}).get('channels', [])
def getPVRBroadcasts(self, id):
param = {"method":"PVR.GetBroadcasts","params":{"channelid":id,"properties":self.getEnums("PVR.Fields.Broadcast", type='items')}}
return self.sendJSON(param).get('result', {}).get('broadcasts', [])
def getPVRBroadcastDetails(self, id):
param = {"method":"PVR.GetBroadcastDetails","params":{"broadcastid":id,"properties":self.getEnums("PVR.Fields.Broadcast", type='items')}}
return self.sendJSON(param).get('result', {}).get('broadcastdetails', [])
def getPVRRecordings(self, media='video', cache=True):
param = {"method":"Files.GetDirectory","params":{"directory":"pvr://recordings/tv/active/","media":media,"properties":self.getEnums("List.Fields.Files", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('files', [])
else: return self.sendJSON(param).get('result', {}).get('files', [])
def getPVRSearches(self, media='video', cache=True):
param = {"method":"Files.GetDirectory","params":{"directory":"pvr://search/tv/savedsearches/","media":media,"properties":self.getEnums("List.Fields.Files", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('files', [])
else: return self.sendJSON(param).get('result', {}).get('files', [])
def getPVRSearchItems(self, id, media='video', cache=True):
param = {"method":"Files.GetDirectory","params":{"directory":f"pvr://search/tv/savedsearches/{id}/","media":media,"properties":self.getEnums("List.Fields.Files", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('files', [])
else: return self.sendJSON(param).get('result', {}).get('files', [])
def getSmartPlaylists(self, type='video', cache=True):
param = {"method":"Files.GetDirectory","params":{"directory":f"special://profile/playlists/{type}/","media":"video","properties":self.getEnums("List.Fields.Files", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('files', [])
else: return self.sendJSON(param).get('result', {}).get('files', [])
def getInfoLabel(self, key, cache=False):
param = {"method":"XBMC.GetInfoLabels","params":{"labels":[key]}}
if cache: return self.cacheJSON(param).get('result', {}).get(key)
else: return self.sendJSON(param).get('result', {}).get(key)
def getInfoBool(self, key, cache=False):
param = {"method":"XBMC.GetInfoBooleans","params":{"booleans":[key]}}
if cache: return self.cacheJSON(param).get('result', {}).get(key)
else: return self.sendJSON(param).get('result', {}).get(key)
def _setRuntime(self, item={}, runtime=0, save=SETTINGS.getSettingBool('Store_Duration')): #set runtime collected by player, accurate meta.
self.cache.set('getRuntime.%s'%(getMD5(item.get('file'))), runtime, checksum=getMD5(item.get('file')), expiration=datetime.timedelta(days=28), json_data=False)
if not item.get('file','plugin://').startswith(tuple(VFS_TYPES)) and save and runtime > 0: self.queDuration(item, runtime=runtime)
def _getRuntime(self, item={}): #get runtime collected by player, else less accurate provider meta
runtime = self.cache.get('getRuntime.%s'%(getMD5(item.get('file'))), checksum=getMD5(item.get('file')), json_data=False)
return (runtime or item.get('resume',{}).get('total') or item.get('runtime') or item.get('duration') or (item.get('streamdetails',{}).get('video',[]) or [{}])[0].get('duration') or 0)
def _setDuration(self, path, item={}, duration=0, save=SETTINGS.getSettingBool('Store_Duration')):#set VideoParser cache
self.cache.set('getDuration.%s'%(getMD5(path)), duration, checksum=getMD5(path), expiration=datetime.timedelta(days=28), json_data=False)
if save and item: self.queDuration(item, duration)
return duration
def _getDuration(self, path): #get VideoParser cache
return (self.cache.get('getDuration.%s'%(getMD5(path)), checksum=getMD5(path), json_data=False) or 0)
def getDuration(self, path, item={}, accurate=bool(SETTINGS.getSettingInt('Duration_Type')), save=SETTINGS.getSettingBool('Store_Duration')):
self.log("getDuration, accurate = %s, path = %s, save = %s" % (accurate, path, save))
if not item: item = {'file':path}
runtime = self._getRuntime(item)
if runtime == 0 or accurate:
duration = 0
if isStack(path):# handle "stacked" videos
for file in splitStacks(path): duration += self.__parseDuration(runtime, file)
else: duration = self.__parseDuration(runtime, path, item, save)
if duration > 0: runtime = duration
self.log("getDuration, returning path = %s, runtime = %s" % (path, runtime))
return runtime
def getTotRuntime(self, items=[]):
total = sum([self._getRuntime(item) for item in items])
self.log("getTotRuntime, items = %s, total = %s" % (len(items), total))
return total
def getTotDuration(self, items=[]):
total = sum([self.getDuration(item.get('file'),item) for item in items])
self.log("getTotDuration, items = %s, total = %s" % (len(items), total))
return total
def __parseDuration(self, runtime, path, item={}, save=SETTINGS.getSettingBool('Store_Duration')):
self.log("__parseDuration, runtime = %s, path = %s, save = %s" % (runtime, path, save))
duration = self.videoParser.getVideoLength(path.replace("\\\\", "\\"), item, self)
if not path.startswith(tuple(VFS_TYPES)):
## duration diff. safe guard, how different are the two values? if > 45% don't save to Kodi.
rundiff = int(percentDiff(runtime, duration))
runsafe = False
if (rundiff <= 45 and rundiff > 0) or (rundiff == 100 and (duration == 0 or runtime == 0)) or (rundiff == 0 and (duration > 0 and runtime > 0)) or (duration > runtime): runsafe = True
self.log("__parseDuration, path = %s, runtime = %s, duration = %s, difference = %s%%, safe = %s" % (path, runtime, duration, rundiff, runsafe))
## save parsed duration to Kodi database, if enabled.
if runsafe:
runtime = duration
if save and not path.startswith(tuple(VFS_TYPES)): self.queDuration(item, duration)
else: runtime = duration
self.log("__parseDuration, returning runtime = %s" % (runtime))
return runtime
def queDuration(self, item={}, duration=0, runtime=0):
mtypes = {'video' : {},
'movie' : {"method":"VideoLibrary.SetMovieDetails" ,"params":{"movieid" :item.get('id',-1) ,"runtime": runtime,"resume": {"position": item.get('position',0.0),"total": duration}}},
'movies' : {"method":"VideoLibrary.SetMovieDetails" ,"params":{"movieid" :item.get('movieid',-1) ,"runtime": runtime,"resume": {"position": item.get('position',0.0),"total": duration}}},
'episode' : {"method":"VideoLibrary.SetEpisodeDetails" ,"params":{"episodeid" :item.get('id',-1) ,"runtime": runtime,"resume": {"position": item.get('position',0.0),"total": duration}}},
'episodes' : {"method":"VideoLibrary.SetEpisodeDetails" ,"params":{"episodeid" :item.get('episodeid',-1) ,"runtime": runtime,"resume": {"position": item.get('position',0.0),"total": duration}}},
'musicvideo' : {"method":"VideoLibrary.SetMusicVideoDetails","params":{"musicvideoid":item.get('id',-1) ,"runtime": runtime,"resume": {"position": item.get('position',0.0),"total": duration}}},
'musicvideos': {"method":"VideoLibrary.SetMusicVideoDetails","params":{"musicvideoid":item.get('musicvideoid',-1) ,"runtime": runtime,"resume": {"position": item.get('position',0.0),"total": duration}}},
'song' : {"method":"AudioLibrary.SetSongDetails" ,"params":{"songid" :item.get('id',-1) ,"runtime": runtime,"resume": {"position": item.get('position',0.0),"total": duration}}},
'songs' : {"method":"AudioLibrary.SetSongDetails" ,"params":{"songid" :item.get('songid',-1) ,"runtime": runtime,"resume": {"position": item.get('position',0.0),"total": duration}}}}
try:
mtype = mtypes.get(item.get('type'))
if mtype.get('params'):
if duration == 0: mtype['params'].pop('resume') #save file duration meta
elif runtime == 0: mtype['params'].pop('runtime') #save player runtime meta
id = (item.get('id') or item.get('movieid') or item.get('episodeid') or item.get('musicvideoid') or item.get('songid'))
self.log('[%s] queDuration, media = %s, duration = %s, runtime = %s'%(id,item['type'],duration,runtime))
self.queueJSON(mtype['params'])
except Exception as e: self.log("queDuration, failed! %s\nmtype = %s\nitem = %s"%(e,mtype,item), xbmc.LOGERROR)
def quePlaycount(self, item, save=SETTINGS.getSettingBool('Rollback_Watched')):
param = {'video' : {},
'movie' : {"method":"VideoLibrary.SetMovieDetails" ,"params":{"movieid" :item.get('id',-1) ,"playcount": item.get('playcount',0),"resume": {"position": item.get('position',0.0),"total": item.get('total',0.0)}}},
'movies' : {"method":"VideoLibrary.SetMovieDetails" ,"params":{"movieid" :item.get('movieid',-1) ,"playcount": item.get('playcount',0),"resume": {"position": item.get('position',0.0),"total": item.get('total',0.0)}}},
'episode' : {"method":"VideoLibrary.SetEpisodeDetails" ,"params":{"episodeid" :item.get('id',-1) ,"playcount": item.get('playcount',0),"resume": {"position": item.get('position',0.0),"total": item.get('total',0.0)}}},
'episodes' : {"method":"VideoLibrary.SetEpisodeDetails" ,"params":{"episodeid" :item.get('episodeid',-1) ,"playcount": item.get('playcount',0),"resume": {"position": item.get('position',0.0),"total": item.get('total',0.0)}}},
'musicvideo' : {"method":"VideoLibrary.SetMusicVideoDetails","params":{"musicvideoid":item.get('id',-1) ,"playcount": item.get('playcount',0),"resume": {"position": item.get('position',0.0),"total": item.get('total',0.0)}}},
'musicvideos': {"method":"VideoLibrary.SetMusicVideoDetails","params":{"musicvideoid":item.get('musicvideoid',-1) ,"playcount": item.get('playcount',0),"resume": {"position": item.get('position',0.0),"total": item.get('total',0.0)}}},
'song' : {"method":"AudioLibrary.SetSongDetails" ,"params":{"songid" :item.get('id',-1) ,"playcount": item.get('playcount',0),"resume": {"position": item.get('position',0.0),"total": item.get('total',0.0)}}},
'songs' : {"method":"AudioLibrary.SetSongDetails" ,"params":{"songid" :item.get('songid',-1) ,"playcount": item.get('playcount',0),"resume": {"position": item.get('position',0.0),"total": item.get('total',0.0)}}}}
if not item.get('file','plugin://').startswith(tuple(VFS_TYPES)):
try:
params = param.get(item.get('type'))
self.log('quePlaycount, params = %s'%(params.get('params',{})))
self.queueJSON(params)
except: pass
def requestList(self, citem, path, media='video', page=SETTINGS.getSettingInt('Page_Limit'), sort={}, limits={}, query={}):
# {"method": "VideoLibrary.GetEpisodes",
# "params": {
# "properties": ["title"],
# "sort": {"ignorearticle": true,
# "method": "label",
# "order": "ascending",
# "useartistsortname": true},
# "limits": {"end": 0, "start": 0},
# "filter": {"and": [{"field": "title", "operator": "contains", "value": "Star Wars"}]}}}
##################################
# {"method": "Files.GetDirectory",
# "params": {
# "directory": "videodb://tvshows/studios/-1/-1/-1/",
# "media": "video",
# "properties": ["title"],
# "sort": {"ignorearticle": true,
# "method": "label",
# "order": "ascending",
# "useartistsortname": true},
# "limits": {"end": 25, "start": 0}}}
param = {}
if query: #library query
getDirectory = False
param['filter'] = query.get('filter',{})
param["properties"] = self.getEnums(query['enum'], type='items')
else: #vfs path
getDirectory = True
param["media"] = media
param["directory"] = escapeDirJSON(path)
param["properties"] = self.getEnums("List.Fields.Files", type='items')
self.log("requestList, id: %s, getDirectory = %s, media = %s, limit = %s, sort = %s, query = %s, limits = %s\npath = %s"%(citem['id'],getDirectory,media,page,sort,query,limits,path))
if limits.get('end',-1) == -1: #-1 unlimited pagination, replace with autoPagination.
limits = self.autoPagination(citem['id'], path, query) #get
self.log('[%s] requestList, autoPagination limits = %s'%(citem['id'],limits))
if limits.get('total',0) > page and sort.get("method","") == "random":
limits = self.randomPagination(page,limits)
self.log('[%s] requestList, generating random limits = %s'%(citem['id'],limits))
param["limits"] = {}
param["limits"]["start"] = 0 if limits.get('end', 0) == -1 else limits.get('end', 0)
param["limits"]["end"] = abs(limits.get('end', 0) + page)
param["sort"] = sort
self.log('[%s] requestList, page = %s\nparam = %s'%(citem['id'], page, param))
if getDirectory:
results = self.getDirectory(param,timeout=float(SETTINGS.getSettingInt('RPC_Timer')*60))
if 'filedetails' in results: key = 'filedetails'
else: key = 'files'
else:
results = self.getLibrary(query['method'],param, cache=False)
key = query.get('key',list(results.keys())[0])
items, limits, errors = results.get(key,[]), results.get('limits',param["limits"]), results.get('error',{})
if (limits.get('end',0) >= limits.get('total',0) or limits.get('start',0) >= limits.get('total',0)):
# restart page to 0, exceeding boundaries.
self.log('[%s] requestList, resetting limits to 0'%(citem['id']))
limits = {"end": 0, "start": 0, "total": limits.get('total',0)}
if len(items) == 0 and limits.get('total',0) > 0:
# retry last request with fresh limits when no items are returned.
self.log("[%s] requestList, trying again with start limits at 0"%(citem['id']))
return self.requestList(citem, path, media, page, sort, {"end": 0, "start": 0, "total": limits.get('total',0)}, query)
else:
self.autoPagination(citem['id'], path, query, limits) #set
self.log("[%s] requestList, return items = %s" % (citem['id'], len(items)))
return items, limits, errors
def resetPagination(self, id, path, query={}, limits={"end": 0, "start": 0, "total":0}):
return self.autoPagination(id, path, query, limits)
def autoPagination(self, id, path, query={}, limits={}):
if not limits: return (self.cache.get('autoPagination.%s.%s.%s'%(id,getMD5(path),getMD5(dumpJSON(query))), checksum=id, json_data=True) or {"end": 0, "start": 0, "total":0})
else: return self.cache.set('autoPagination.%s.%s.%s'%(id,getMD5(path),getMD5(dumpJSON(query))), limits, checksum=id, expiration=datetime.timedelta(days=28), json_data=True)
def randomPagination(self, page=SETTINGS.getSettingInt('Page_Limit'), limits={}, start=0):
if limits.get('total',0) > page: start = random.randrange(0, (limits.get('total',0)-page), page)
return {"end": start, "start": start, "total":limits.get('total',0)}
@cacheit(checksum=PROPERTIES.getInstanceID())
def buildWebBase(self, local=False):
port = 80
username = 'kodi'
password = ''
secure = False
enabled = True
settings = self.getSetting('control','services')
for setting in settings:
if setting.get('id','').lower() == 'services.webserver' and not setting.get('value'):
enabled = False
DIALOG.notificationDialog(LANGUAGE(32131))
break
if setting.get('id','').lower() == 'services.webserverusername': username = setting.get('value')
elif setting.get('id','').lower() == 'services.webserverport': port = setting.get('value')
elif setting.get('id','').lower() == 'services.webserverpassword': password = setting.get('value')
elif setting.get('id','').lower() == 'services.webserverssl' and setting.get('value'): secure = True
username = '{0}:{1}@'.format(username, password) if username and password else ''
protocol = 'https' if secure else 'http'
if local: ip = 'localhost'
else: ip = SETTINGS.getIP()
webURL = '{0}://{1}{2}:{3}'.format(protocol,username,ip, port)
self.log("buildWebBase; returning %s"%(webURL))
return webURL
def padItems(self, files, page=SETTINGS.getSettingInt('Page_Limit')):
# Balance media limits, by filling with duplicates to meet min. pagination.
self.log("padItems; files In = %s"%(len(files)))
if len(files) < page:
iters = cycle(files)
while not self.service.monitor.abortRequested() and (len(files) < page and len(files) > 0):
item = next(iters).copy()
if self.service.monitor.waitForAbort(0.0001): break
elif self.getDuration(item.get('file'),item) == 0:
try: files.pop(files.index(item))
except: break
else: files.append(item)
self.log("padItems; files Out = %s"%(len(files)))
return files
def inputFriendlyName(self):
friendly = self.getSettingValue("services.devicename")
self.log("inputFriendlyName, name = %s"%(friendly))
if not friendly or friendly.lower() == 'kodi':
with PROPERTIES.interruptActivity():
if DIALOG.okDialog(LANGUAGE(32132)%(friendly)):
friendly = DIALOG.inputDialog(LANGUAGE(30122), friendly)
if not friendly or friendly.lower() == 'kodi':
return self.inputFriendlyName()
else:
self.setSettingValue("services.devicename",friendly,queue=False)
self.log('inputFriendlyName, setting device name = %s'%(friendly))
return friendly
def getCallback(self, sysInfo={}):
self.log('[%s] getCallback, mode = %s, radio = %s, isPlaylist = %s'%(sysInfo.get('chid'),sysInfo.get('mode'),sysInfo.get('radio',False),sysInfo.get('isPlaylist',False)))
def _matchJSON():#requires 'pvr://' json whitelisting
results = self.getDirectory(param={"directory":"pvr://channels/{dir}/".format(dir={'True':'radio','False':'tv'}[str(sysInfo.get('radio',False))])}, cache=False).get('files',[])
for dir in [ADDON_NAME,'All channels']: #todo "All channels" may not work with non-English translations!
for result in results:
if result.get('label','').lower().startswith(dir.lower()):
self.log('getCallback: _matchJSON, found dir = %s'%(result.get('file')))
channels = self.getDirectory(param={"directory":result.get('file')},checksum=PROPERTIES.getInstanceID(),expiration=datetime.timedelta(minutes=FIFTEEN)).get('files',[])
for item in channels:
if item.get('label','').lower() == sysInfo.get('name','').lower() and decodePlot(item.get('plot','')).get('citem',{}).get('id') == sysInfo.get('chid'):
self.log('[%s] getCallback: _matchJSON, found file = %s'%(sysInfo.get('chid'),item.get('file')))
return item.get('file')
if sysInfo.get('mode','').lower() == 'live' and sysInfo.get('chpath'):
callback = sysInfo.get('chpath')
elif sysInfo.get('isPlaylist'):
callback = sysInfo.get('citem',{}).get('url')
elif sysInfo.get('mode','').lower() == 'vod' and sysInfo.get('nitem',{}).get('file'):
callback = sysInfo.get('nitem',{}).get('file')
else:
callback = sysInfo.get('callback','')
if not callback: callback = _matchJSON()
self.log('getCallback: returning callback = %s'%(callback))
return callback# or (('%s%s'%(self.sysARG[0],self.sysARG[2])).split('%s&'%(slugify(ADDON_NAME))))[0])
def matchChannel(self, chname: str, id: str, radio: bool=False, extend=True):
self.log('[%s] matchChannel, chname = %s, radio = %s'%(id,chname,radio))
def __match():
channels = self.getPVRChannels(radio)
for channel in channels:
if channel.get('label','').lower() == chname.lower():
for key in ['broadcastnow', 'broadcastnext']:
if decodePlot(channel.get(key,{}).get('plot','')).get('citem',{}).get('id') == id:
channel['broadcastnext'] = [channel.get('broadcastnext',{})]
self.log('[%s] matchChannel: __match, found pvritem = %s'%(id,channel))
return channel
def __extend(pvritem: dict={}) -> dict:
channelItem = {}
def _parseBroadcast(broadcast={}):
if broadcast.get('progresspercentage',0) == 100:
channelItem.setdefault('broadcastpast',[]).append(broadcast)
elif broadcast.get('progresspercentage',0) > 0 and broadcast.get('progresspercentage',100) < 100:
channelItem['broadcastnow'] = broadcast
elif broadcast.get('progresspercentage',0) == 0 and broadcast.get('progresspercentage',100) < 100:
channelItem.setdefault('broadcastnext',[]).append(broadcast)
broadcasts = self.getPVRBroadcasts(pvritem.get('channelid',{}))
[_parseBroadcast(broadcast) for broadcast in broadcasts]
pvritem['broadcastnext'] = channelItem.get('broadcastnext',pvritem['broadcastnext'])
self.log('matchChannel: __extend, broadcastnext = %s entries'%(len(pvritem['broadcastnext'])))
return pvritem
cacheName = 'matchChannel.%s'%(getMD5('%s.%s.%s.%s'%(chname,id,radio,extend)))
cacheResponse = (self.cache.get(cacheName, checksum=PROPERTIES.getInstanceID(), json_data=True) or {})
if not cacheResponse:
pvrItem = __match()
if pvrItem:
if extend: pvrItem = __extend(pvrItem)
cacheResponse = self.cache.set(cacheName, pvrItem, checksum=PROPERTIES.getInstanceID(), expiration=datetime.timedelta(seconds=FIFTEEN), json_data=True)
else: return {}
return cacheResponse
def getNextItem(self, citem={}, nitem={}): #return next broadcast ignoring fillers
if not nitem: nitem = decodePlot(BUILTIN.getInfoLabel('NextPlot','VideoPlayer'))
nextitems = sorted(self.matchChannel(citem.get('name',''), citem.get('id',''), citem.get('radio',False)).get('broadcastnext',[]), key=itemgetter('starttime'))
for nextitem in nextitems:
if not isFiller(nextitem): return decodePlot(nextitem.get('plot',''))
return nitem
def toggleShowLog(self, state=False):
self.log('toggleShowLog, state = %s'%(state))
if SETTINGS.getSettingBool('Enable_PVR_RELOAD'): #check that users allow alternations to kodi.
opState = not bool(state)
if self.getSettingValue("debug.showloginfo") == opState:
self.setSettingValue("debug.showloginfo",state,queue=False)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,507 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from predefined import Predefined
from resources import Resources
from channels import Channels
#constants
REG_KEY = 'PseudoTV_Recommended.%s'
class Service:
from jsonrpc import JSONRPC
player = PLAYER()
monitor = MONITOR()
jsonRPC = JSONRPC()
def _interrupt(self) -> bool:
return PROPERTIES.isPendingInterrupt()
def _suspend(self) -> bool:
return PROPERTIES.isPendingSuspend()
class Library:
def __init__(self, service=None):
if service is None: service = Service()
self.service = service
self.jsonRPC = service.jsonRPC
self.cache = service.jsonRPC.cache
self.predefined = Predefined()
self.channels = Channels()
self.resources = Resources(service=self.service)
self.pCount = 0
self.pDialog = None
self.pMSG = ''
self.pHeader = ''
self.libraryDATA = getJSON(LIBRARYFLE_DEFAULT)
self.libraryTEMP = self.libraryDATA['library'].pop('Item')
self.libraryDATA.update(self._load())
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def _load(self, file=LIBRARYFLEPATH):
return getJSON(file)
def _save(self, file=LIBRARYFLEPATH):
self.libraryDATA['uuid'] = SETTINGS.getMYUUID()
return setJSON(file, self.libraryDATA)
def getLibrary(self, type=None):
self.log('getLibrary, type = %s'%(type))
if type is None: return self.libraryDATA.get('library',{})
else: return self.libraryDATA.get('library',{}).get(type,[])
def enableByName(self, type, names=[]):
self.log('enableByName, type = %s, names = %s'%(type, names))
items = self.getLibrary(type)
for name in names:
for item in items:
if name.lower() == item.get('name','').lower(): item['enabled'] = True
else: item['enabled'] = False
return self.setLibrary(type, items)
def setLibrary(self, type, items=[]):
self.log('setLibrary, type = %s, items = %s'%(type,len(items)))
self.libraryDATA['library'][type] = items
enabled = self.getEnabled(type, items)
PROPERTIES.setEXTPropertyBool('%s.has.%s'%(ADDON_ID,slugify(type)),len(items) > 0)
PROPERTIES.setEXTPropertyBool('%s.has.%s.enabled'%(ADDON_ID,slugify(type)),len(enabled) > 0)
SETTINGS.setSetting('Select_%s'%(slugify(type)),'[COLOR=orange][B]%s[/COLOR][/B]/[COLOR=dimgray]%s[/COLOR]'%(len(enabled),len(items)))
return self._save()
def getEnabled(self, type, items=None):
if items is None: items = self.getLibrary(type)
return [item for item in items if item.get('enabled',False)]
def updateLibrary(self, force: bool=False) -> bool:
def __funcs():
return {
"Playlists" :{'func':self.getPlaylists ,'life':datetime.timedelta(minutes=FIFTEEN)},
"TV Networks" :{'func':self.getNetworks ,'life':datetime.timedelta(days=MAX_GUIDEDAYS)},
"TV Shows" :{'func':self.getTVShows ,'life':datetime.timedelta(hours=MAX_GUIDEDAYS)},
"TV Genres" :{'func':self.getTVGenres ,'life':datetime.timedelta(days=MAX_GUIDEDAYS)},
"Movie Genres" :{'func':self.getMovieGenres ,'life':datetime.timedelta(days=MAX_GUIDEDAYS)},
"Movie Studios":{'func':self.getMovieStudios,'life':datetime.timedelta(days=MAX_GUIDEDAYS)},
"Mixed Genres" :{'func':self.getMixedGenres ,'life':datetime.timedelta(days=MAX_GUIDEDAYS)},
"Mixed" :{'func':self.getMixed ,'life':datetime.timedelta(minutes=FIFTEEN)},
"Recommended" :{'func':self.getRecommend ,'life':datetime.timedelta(hours=MAX_GUIDEDAYS)},
"Services" :{'func':self.getServices ,'life':datetime.timedelta(hours=MAX_GUIDEDAYS)},
"Music Genres" :{'func':self.getMusicGenres ,'life':datetime.timedelta(days=MAX_GUIDEDAYS)}
}
def __fill(type, func):
try: items = func()
except Exception as e:
self.log("__fill, %s failed! %s"%(type,e), xbmc.LOGERROR)
items = []
self.log('__fill, returning %s (%s)'%(type,len(items)))
return items
def __update(type, items, existing=[]):
if not existing: existing = self.channels.getType(type)
self.log('__update, type = %s, items = %s, existing = %s'%(type,len(items),len(existing)))
for item in items:
if not item.get('enabled',False):
for eitem in existing:
if getChannelSuffix(item.get('name'), type).lower() == eitem.get('name','').lower():
if eitem['logo'] not in [LOGO,COLOR_LOGO] and item['logo'] in [LOGO,COLOR_LOGO]: item['logo'] = eitem['logo']
item['enabled'] = True
break
item['logo'] = self.resources.getLogo(item,item.get('logo',LOGO)) #update logo
entry = self.libraryTEMP.copy()
entry.update(item)
yield entry
if force: #clear library cache.
with BUILTIN.busy_dialog():
for label, params in list(__funcs().items()):
DIALOG.notificationDialog(LANGUAGE(30070)%(label),time=5)
self.cache.clear("%s.%s"%(self.__class__.__name__,params['func'].__name__),wait=5)
complete = True
types = list(__funcs().keys())
for idx, type in enumerate(types):
self.pMSG = type
self.pCount = int(idx*100//len(types))
self.pHeader = '%s, %s %s'%(ADDON_NAME,LANGUAGE(32028),LANGUAGE(32041))
self.pDialog = DIALOG.progressBGDialog(header=self.pHeader)
if (self.service._interrupt() or self.service._suspend()) and PROPERTIES.hasFirstRun():
self.log("updateLibrary, _interrupt")
complete = False
self.pDialog = DIALOG.progressBGDialog(self.pCount, self.pDialog, '%s: %s'%(LANGUAGE(32144),LANGUAGE(32213)), self.pHeader)
break
self.pDialog = DIALOG.progressBGDialog(self.pCount, self.pDialog, self.pMSG, self.pHeader)
cacheResponse = self.cache.get("%s.%s"%(self.__class__.__name__,__funcs()[type]['func'].__name__))
if not cacheResponse:
self.pHeader = '%s, %s %s'%(ADDON_NAME,LANGUAGE(32022),LANGUAGE(32041))
cacheResponse = self.cache.set("%s.%s"%(self.__class__.__name__,__funcs()[type]['func'].__name__), __fill(type, __funcs()[type]['func']), expiration=__funcs()[type]['life'])
if complete:
self.setLibrary(type, list(__update(type,cacheResponse,self.getEnabled(type))))
self.log("updateLibrary, type = %s, saved items = %s"%(type,len(cacheResponse)))
self.pDialog = DIALOG.progressBGDialog(100, self.pDialog, header='%s, %s %s'%(ADDON_NAME,LANGUAGE(32041),LANGUAGE(32025)))
self.log('updateLibrary, force = %s, complete = %s'%(force, complete))
return complete
def resetLibrary(self, ATtypes=AUTOTUNE_TYPES):
self.log('resetLibrary')
for ATtype in ATtypes:
items = self.getLibrary(ATtype)
for item in items:
item['enabled'] = False #disable everything before selecting new items.
self.setLibrary(ATtype, items)
def updateProgress(self, percent, message, header):
if self.pDialog: self.pDialog = DIALOG.progressBGDialog(percent, self.pDialog, message=message, header=header)
def getNetworks(self):
return self.getTVInfo().get('studios',[])
def getTVGenres(self):
return self.getTVInfo().get('genres',[])
def getTVShows(self):
return self.getTVInfo().get('shows',[])
def getMovieStudios(self):
return self.getMovieInfo().get('studios',[])
def getMovieGenres(self):
return self.getMovieInfo().get('genres',[])
def getMusicGenres(self):
return self.getMusicInfo().get('genres',[])
def getMixedGenres(self):
MixedGenreList = []
tvGenres = self.getTVGenres()
movieGenres = self.getMovieGenres()
for tv in [tv for tv in tvGenres for movie in movieGenres if tv.get('name','').lower() == movie.get('name','').lower()]:
MixedGenreList.append({'name':tv.get('name'),'type':"Mixed Genres",'path':self.predefined.createGenreMixedPlaylist(tv.get('name')),'logo':tv.get('logo'),'rules':{"800":{"values":{"0":tv.get('name')}}}})
self.log('getMixedGenres, genres = %s' % (len(MixedGenreList)))
return sorted(MixedGenreList,key=itemgetter('name'))
def getMixed(self):
MixedList = []
MixedList.append({'name':LANGUAGE(32001), 'type':"Mixed",'path':self.predefined.createMixedRecent() ,'logo':self.resources.getLogo({'name':LANGUAGE(32001),'type':"Mixed"})}) #"Recently Added"
MixedList.append({'name':LANGUAGE(32002), 'type':"Mixed",'path':self.predefined.createSeasonal() ,'logo':self.resources.getLogo({'name':LANGUAGE(32002),'type':"Mixed"}),'rules':{"800":{"values":{"0":LANGUAGE(32002)}}}}) #"Seasonal"
MixedList.extend(self.getPVRRecordings())#"PVR Recordings"
MixedList.extend(self.getPVRSearches()) #"PVR Searches"
self.log('getMixed, mixed = %s' % (len(MixedList)))
return sorted(MixedList,key=itemgetter('name'))
def getPVRRecordings(self):
recordList = []
json_response = self.jsonRPC.getPVRRecordings()
paths = [item.get('file') for idx, item in enumerate(json_response) if item.get('label','').endswith('(%s)'%(ADDON_NAME))]
if len(paths) > 0: recordList.append({'name':LANGUAGE(32003),'type':"Mixed",'path':[paths],'logo':self.resources.getLogo({'name':LANGUAGE(32003),'type':"Mixed"})})
self.log('getPVRRecordings, recordings = %s' % (len(recordList)))
return sorted(recordList,key=itemgetter('name'))
def getPVRSearches(self):
searchList = []
json_response = self.jsonRPC.getPVRSearches()
for idx, item in enumerate(json_response):
if not item.get('file'): continue
searchList.append({'name':"%s (%s)"%(item.get('label',LANGUAGE(32241)),LANGUAGE(32241)),'type':"Mixed",'path':[item.get('file')],'logo':self.resources.getLogo({'name':item.get('label',LANGUAGE(32241)),'type':"Mixed"})})
self.log('getPVRSearches, searches = %s' % (len(searchList)))
return sorted(searchList,key=itemgetter('name'))
def getPlaylists(self):
PlayList = []
for type in ['video','mixed','music']:
self.updateProgress(self.pCount,'%s: %s'%(self.pMSG,LANGUAGE(32140)),self.pHeader)
results = self.jsonRPC.getSmartPlaylists(type)
for idx, result in enumerate(results):
self.updateProgress(self.pCount,'%s (%s): %s%%'%(self.pMSG,type.title(),int((idx)*100//len(results))),self.pHeader)
if not result.get('label'): continue
logo = result.get('thumbnail')
if not logo: logo = self.resources.getLogo({'name':result.get('label',''),'type':"Custom"})
PlayList.append({'name':result.get('label'),'type':"%s Playlist"%(type.title()),'path':[result.get('file')],'logo':logo})
self.log('getPlaylists, PlayList = %s' % (len(PlayList)))
PlayList = sorted(PlayList,key=itemgetter('name'))
PlayList = sorted(PlayList,key=itemgetter('type'))
return PlayList
@cacheit()
def getTVInfo(self, sortbycount=True):
self.log('getTVInfo')
if BUILTIN.hasTV():
NetworkList = Counter()
ShowGenreList = Counter()
TVShows = Counter()
self.updateProgress(self.pCount,'%s: %s'%(self.pMSG,LANGUAGE(32140)),self.pHeader)
json_response = self.jsonRPC.getTVshows()
for idx, info in enumerate(json_response):
self.updateProgress(self.pCount,'%s: %s%%'%(self.pMSG,int((idx)*100//len(json_response))),self.pHeader)
if not info.get('label'): continue
TVShows.update({json.dumps({'name': info.get('label'), 'type':"TV Shows", 'path': self.predefined.createShowPlaylist(info.get('label')), 'logo': info.get('art', {}).get('clearlogo', ''),'rules':{"800":{"values":{"0":info.get('label')}}}}): info.get('episode', 0)})
NetworkList.update([studio for studio in info.get('studio', [])])
ShowGenreList.update([genre for genre in info.get('genre', [])])
if sortbycount:
TVShows = [json.loads(x[0]) for x in sorted(TVShows.most_common(250))]
NetworkList = [x[0] for x in sorted(NetworkList.most_common(50))]
ShowGenreList = [x[0] for x in sorted(ShowGenreList.most_common(25))]
else:
TVShows = (sorted(map(json.loads, list(TVShows.keys())), key=itemgetter('name')))
del TVShows[250:]
NetworkList = (sorted(set(list(NetworkList.keys()))))
del NetworkList[250:]
ShowGenreList = (sorted(set(list(ShowGenreList.keys()))))
#search resources for studio/genre logos
nNetworkList = []
for idx, network in enumerate(NetworkList):
self.updateProgress(self.pCount,'%s: %s%%'%(self.pMSG,int((idx)*100//len(NetworkList))),self.pHeader)
nNetworkList.append({'name':network, 'type':"TV Networks", 'path': self.predefined.createNetworkPlaylist(network),'logo':self.resources.getLogo({'name':network,'type':"TV Networks"}),'rules':{"800":{"values":{"0":network}}}})
NetworkList = nNetworkList
nShowGenreList = []
for idx, tvgenre in enumerate(ShowGenreList):
self.updateProgress(self.pCount,'%s: %s%%'%(self.pMSG,int((idx)*100//len(ShowGenreList))),self.pHeader)
nShowGenreList.append({'name':tvgenre, 'type':"TV Genres" , 'path': self.predefined.createTVGenrePlaylist(tvgenre),'logo':self.resources.getLogo({'name':tvgenre,'type':"TV Genres"}),'rules':{"800":{"values":{"0":tvgenre}}}})
ShowGenreList = nShowGenreList
else: NetworkList = ShowGenreList = TVShows = []
self.log('getTVInfo, networks = %s, genres = %s, shows = %s' % (len(NetworkList), len(ShowGenreList), len(TVShows)))
return {'studios':NetworkList,'genres':ShowGenreList,'shows':TVShows}
@cacheit()
def getMovieInfo(self, sortbycount=True):
self.log('getMovieInfo')
if BUILTIN.hasMovie():
StudioList = Counter()
MovieGenreList = Counter()
self.updateProgress(self.pCount,'%s: %s'%(self.pMSG,LANGUAGE(32140)),self.pHeader)
json_response = self.jsonRPC.getMovies() #we can't parse for genres directly from Kodi json ie.getGenres; because we need the weight of each genre to prioritize list.
for idx, info in enumerate(json_response):
StudioList.update([studio for studio in info.get('studio', [])])
MovieGenreList.update([genre for genre in info.get('genre', [])])
if sortbycount:
StudioList = [x[0] for x in sorted(StudioList.most_common(25))]
MovieGenreList = [x[0] for x in sorted(MovieGenreList.most_common(25))]
else:
StudioList = (sorted(set(list(StudioList.keys()))))
del StudioList[250:]
MovieGenreList = (sorted(set(list(MovieGenreList.keys()))))
#search resources for studio/genre logos
nStudioList = []
for idx, studio in enumerate(StudioList):
self.updateProgress(self.pCount,'%s: %s%%'%(self.pMSG,int((idx)*100//len(StudioList))),self.pHeader)
nStudioList.append({'name':studio, 'type':"Movie Studios", 'path': self.predefined.createStudioPlaylist(studio) ,'logo':self.resources.getLogo({'name':studio,'type':"Movie Studios"}),'rules':{"800":{"values":{"0":studio}}}})
StudioList = nStudioList
nMovieGenreList = []
for idx, genre in enumerate(MovieGenreList):
self.updateProgress(self.pCount,'%s: %s%%'%(self.pMSG,int((idx)*100//len(MovieGenreList))),self.pHeader)
nMovieGenreList.append({'name':genre, 'type':"Movie Genres" , 'path': self.predefined.createMovieGenrePlaylist(genre) ,'logo':self.resources.getLogo({'name':genre,'type':"Movie Genres"}) ,'rules':{"800":{"values":{"0":genre}}}})
MovieGenreList = nMovieGenreList
else: StudioList = MovieGenreList = []
self.log('getMovieInfo, studios = %s, genres = %s' % (len(StudioList), len(MovieGenreList)))
return {'studios':StudioList,'genres':MovieGenreList}
@cacheit()
def getMusicInfo(self, sortbycount=True):
self.log('getMusicInfo')
if BUILTIN.hasMusic():
MusicGenreList = Counter()
self.updateProgress(self.pCount,'%s: %s'%(self.pMSG,LANGUAGE(32140)),self.pHeader)
json_response = self.jsonRPC.getMusicGenres()
for idx, info in enumerate(json_response):
MusicGenreList.update([genre.strip() for genre in info.get('label','').split(';')])
if sortbycount:
MusicGenreList = [x[0] for x in sorted(MusicGenreList.most_common(50))]
else:
MusicGenreList = (sorted(set(list(MusicGenreList.keys()))))
del MusicGenreList[250:]
MusicGenreList = (sorted(set(list(MusicGenreList.keys()))))
#search resources for studio/genre logos
nMusicGenreList = []
for idx, genre in enumerate(MusicGenreList):
self.updateProgress(self.pCount,'%s: %s%%'%(self.pMSG,int((idx)*100//len(MusicGenreList))),self.pHeader)
nMusicGenreList.append({'name':genre, 'type':"Music Genres", 'path': self.predefined.createMusicGenrePlaylist(genre),'logo':self.resources.getLogo({'name':genre,'type':"Music Genres"})})
MusicGenreList = nMusicGenreList
else: MusicGenreList = []
self.log('getMusicInfo, found genres = %s' % (len(MusicGenreList)))
return {'genres':MusicGenreList}
def getRecommend(self):
self.log('getRecommend')
PluginList = []
WhiteList = self.getWhiteList()
AddonsList = self.searchRecommended()
for addonid, item in list(AddonsList.items()):
if addonid not in WhiteList: continue
items = item.get('data',{}).get('vod',[])
items.extend(item.get('data',{}).get('live',[]))
for vod in items:
path = vod.get('path')
if not isinstance(path,list): path = [path]
PluginList.append({'id':item['meta'].get('name'), 'name':vod.get('name'), 'type':"Recommended", 'path': path, 'logo':vod.get('icon',item['meta'].get('thumbnail'))})
self.log('getRecommend, found (%s) vod items.' % (len(PluginList)))
PluginList = sorted(PluginList,key=itemgetter('name'))
PluginList = sorted(PluginList,key=itemgetter('id'))
return PluginList
def getRecommendInfo(self, addonid):
self.log('getRecommendInfo, addonid = %s'%(addonid))
return self.searchRecommended().get(addonid,{})
def searchRecommended(self):
return {} #todo
# def _search(addonid):
# cacheName = 'searchRecommended.%s'%(getMD5(addonid))
# addonMeta = SETTINGS.getAddonDetails(addonid)
# payload = PROPERTIES.getEXTProperty(REG_KEY%(addonid))
# if not payload: #startup services may not be broadcasting beacon; use last cached beacon instead.
# payload = self.cache.get(cacheName, checksum=addonMeta.get('version',ADDON_VERSION), json_data=True)
# else:
# payload = loadJSON(payload)
# self.cache.set(cacheName, payload, checksum=addonMeta.get('version',ADDON_VERSION), expiration=datetime.timedelta(days=MAX_GUIDEDAYS), json_data=True)
# if payload:
# self.log('searchRecommended, found addonid = %s, payload = %s'%(addonid,payload))
# return addonid,{"data":payload,"meta":addonMeta}
# addonList = sorted(list(set([_f for _f in [addon.get('addonid') for addon in list([k for k in self.jsonRPC.getAddons() if k.get('addonid','') not in self.getBlackList()])] if _f])))
# return dict([_f for _f in [_search(addonid) for addonid in addonList] if _f])
def getServices(self):
self.log('getServices')
return []
def getWhiteList(self):
#whitelist - prompt shown, added to import list and/or manager dropdown.
return self.libraryDATA.get('whitelist',[])
def setWhiteList(self, data=[]):
self.libraryDATA['whitelist'] = sorted(set(data))
return self._save()
def getBlackList(self):
#blacklist - plugin ignored for the life of the list.
return self.libraryDATA.get('blacklist',[])
def setBlackList(self, data=[]):
self.libraryDATA['blacklist'] = sorted(set(data))
return self._save()
def addWhiteList(self, addonid):
self.log('addWhiteList, addonid = %s'%(addonid))
whiteList = self.getWhiteList()
whiteList.append(addonid)
whiteList = sorted(set(whiteList))
if len(whiteList) > 0: PROPERTIES.setEXTPropertyBool('%s.has.WhiteList'%(ADDON_ID),len(whiteList) > 0)
return self.setWhiteList(whiteList)
def addBlackList(self, addonid):
self.log('addBlackList, addonid = %s'%(addonid))
blackList = self.getBlackList()
blackList.append(addonid)
blackList = sorted(set(blackList))
return self.setBlackList(blackList)
def clearBlackList(self):
return self.setBlackList()
def importPrompt(self):
addonList = self.searchRecommended()
ignoreList = self.getWhiteList()
ignoreList.extend(self.getBlackList()) #filter addons previously parsed.
addonNames = sorted(list(set([_f for _f in [item.get('meta',{}).get('name') for addonid, item in list(addonList.items()) if not addonid in ignoreList] if _f])))
self.log('importPrompt, addonNames = %s'%(len(addonNames)))
try:
if len(addonNames) > 1:
retval = DIALOG.yesnoDialog('%s'%(LANGUAGE(32055)%(ADDON_NAME,', '.join(addonNames))), customlabel=LANGUAGE(32056))
self.log('importPrompt, prompt retval = %s'%(retval))
if retval == 1: raise Exception('Single Entry')
elif retval == 2:
for addonid, item in list(addonList.items()):
if item.get('meta',{}).get('name') in addonNames:
self.addWhiteList(addonid)
else: raise Exception('Single Entry')
except Exception as e:
self.log('importPrompt, %s'%(e))
for addonid, item in list(addonList.items()):
if item.get('meta',{}).get('name') in addonNames:
if not DIALOG.yesnoDialog('%s'%(LANGUAGE(32055)%(ADDON_NAME,item['meta'].get('name','')))):
self.addBlackList(addonid)
else:
self.addWhiteList(addonid)
PROPERTIES.setEXTPropertyBool('%s.has.WhiteList'%(ADDON_ID),len(self.getWhiteList()) > 0)
PROPERTIES.setEXTPropertyBool('%s.has.BlackList'%(ADDON_ID),len(self.getBlackList()) > 0)
SETTINGS.setSetting('Clear_BlackList','|'.join(self.getBlackList()))

View File

@@ -0,0 +1,92 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
#
# -*- coding: utf-8 -*-
import json,traceback
from globals import *
def log(event, level=xbmc.LOGDEBUG):
"""
Logs an event or message using Kodi's logging system.
This function is designed to capture and log debug events based on the specified log level.
If debugging is enabled or the log level is critical (e.g., errors), the event will be
recorded in Kodi's log file. Additionally, it stores events in a custom debug window property
for debugging purposes.
Args:
event (str): The message or event to log.
level (int, optional): The log level (default is xbmc.LOGDEBUG). Supported levels:
- xbmc.LOGDEBUG: Debug messages (low priority).
- xbmc.LOGINFO: Informational messages.
- xbmc.LOGWARNING: Warnings.
- xbmc.LOGERROR: Errors.
- xbmc.LOGFATAL: Fatal errors.
Behavior:
- Logs the event if debugging is enabled or if the log level is above the configured threshold.
- Appends a traceback for error-level logs (level >= xbmc.LOGERROR).
- Formats the log message with the add-on ID and version for context.
- Stores the log entry in the global debug window property for later retrieval if debugging is enabled.
Example Usage:
log("This is a debug message", xbmc.LOGDEBUG)
log("An error occurred", xbmc.LOGERROR)
Notes:
- The `REAL_SETTINGS.getSetting('Debug_Enable')` setting determines whether to log debug-level messages.
- The log entries are stored in a JSON object with timestamps and log levels for easy parsing.
Returns:
None
"""
if REAL_SETTINGS.getSetting('Debug_Enable') == 'true' or level >= 3:
DEBUG_NAMES = {0: 'LOGDEBUG', 1: 'LOGINFO', 2: 'LOGWARNING', 3: 'LOGERROR', 4: 'LOGFATAL'}
DEBUG_LEVELS = {0: xbmc.LOGDEBUG, 1: xbmc.LOGINFO, 2: xbmc.LOGWARNING, 3: xbmc.LOGERROR, 4: xbmc.LOGFATAL}
DEBUG_LEVEL = DEBUG_LEVELS[int((REAL_SETTINGS.getSetting('Debug_Level') or "3"))]
# Add traceback for error-level events
if level >= 3:
event = '%s\n%s' % (event, traceback.format_exc())
# Format event with add-on ID and version
event = '%s-%s-%s' % (ADDON_ID, ADDON_VERSION, event)
# Log the event if the level is above the configured debug level
if level >= DEBUG_LEVEL:
xbmc.log(event, level)
try:
entries = json.loads(xbmcgui.Window(10000).getProperty('%s.debug.log' % (ADDON_ID))).get('DEBUG', {})
except:
entries = {}
# Add the event to the debug entries
entries.setdefault(DEBUG_NAMES[DEBUG_LEVEL], []).append(
'%s - %s: %s' % (datetime.datetime.fromtimestamp(time.time()).strftime(DTFORMAT), DEBUG_NAMES[level], event)
)
# Store the debug entries in the window property
try:
xbmcgui.Window(10000).setProperty('%s.debug.log' % (ADDON_ID), json.dumps({'DEBUG': entries}, indent=4))
except:
pass
# Mark the debug property as active
if not xbmcgui.Window(10000).getProperty('%s.has.debug' % (ADDON_ID)) == 'true':
xbmcgui.Window(10000).setProperty('%s.has.debug' % (ADDON_ID), 'true')

View File

@@ -0,0 +1,451 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# https://github.com/kodi-pvr/pvr.iptvsimple#supported-m3u-and-xmltv-elements
# -*- coding: utf-8 -*-
from globals import *
from channels import Channels
M3U_TEMP = {"id" : "",
"number" : 0,
"name" : "",
"logo" : "",
"group" : [],
"catchup" : "vod",
"radio" : False,
"favorite" : False,
"realtime" : False,
"media" : "",
"label" : "",
"url" : "",
"tvg-shift" : "",
"x-tvg-url" : "",
"media-dir" : "",
"media-size" : "",
"media-type" : "",
"catchup-source" : "",
"catchup-days" : "",
"catchup-correction": "",
"provider" : "",
"provider-type" : "",
"provider-logo" : "",
"provider-countries": "",
"provider-languages": "",
"x-playlist-type" : "",
"kodiprops" : []}
M3U_MIN = {"id" : "",
"number" : 0,
"name" : "",
"logo" : "",
"group" : [],
"catchup" : "vod",
"radio" : False,
"label" : "",
"url" : ""}
class M3U:
def __init__(self):
stations, recordings = self.cleanSelf(list(self._load()))
self.M3UDATA = {'data':'#EXTM3U tvg-shift="" x-tvg-url="" x-tvg-id="" catchup-correction=""', 'stations':stations, 'recordings':recordings}
# self.M3UTEMP = getJSON(M3UFLE_DEFAULT)
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def _load(self, file=M3UFLEPATH):
self.log('_load, file = %s'%file)
if file.startswith('http'):
url = file
file = os.path.join(TEMP_LOC,slugify(url))
saveURL(url,file)
if FileAccess.exists(file):
fle = FileAccess.open(file, 'r')
lines = (fle.readlines())
fle.close()
chCount = 0
data = {}
filter = []
for idx, line in enumerate(lines):
line = line.rstrip()
if line.startswith('#EXTM3U'):
data = {'tvg-shift' :re.compile('tvg-shift=\"(.*?)\"' , re.IGNORECASE).search(line),
'x-tvg-url' :re.compile('x-tvg-url=\"(.*?)\"' , re.IGNORECASE).search(line),
'catchup-correction':re.compile('catchup-correction=\"(.*?)\"' , re.IGNORECASE).search(line)}
# if SETTINGS.getSettingInt('Import_XMLTV_TYPE') == 2 and file == os.path.join(TEMP_LOC,slugify(SETTINGS.getSetting('Import_M3U_URL'))):
# if data.get('x-tvg-url').group(1):
# self.log('_load, using #EXTM3U "x-tvg-url"')
# SETTINGS.setSetting('Import_XMLTV_M3U',data.get('x-tvg-url').group(1))
elif line.startswith('#EXTINF:'):
chCount += 1
match = {'label' :re.compile(',(.*)' , re.IGNORECASE).search(line),
'id' :re.compile('tvg-id=\"(.*?)\"' , re.IGNORECASE).search(line),
'name' :re.compile('tvg-name=\"(.*?)\"' , re.IGNORECASE).search(line),
'group' :re.compile('group-title=\"(.*?)\"' , re.IGNORECASE).search(line),
'number' :re.compile('tvg-chno=\"(.*?)\"' , re.IGNORECASE).search(line),
'logo' :re.compile('tvg-logo=\"(.*?)\"' , re.IGNORECASE).search(line),
'radio' :re.compile('radio=\"(.*?)\"' , re.IGNORECASE).search(line),
'tvg-shift' :re.compile('tvg-shift=\"(.*?)\"' , re.IGNORECASE).search(line),
'catchup' :re.compile('catchup=\"(.*?)\"' , re.IGNORECASE).search(line),
'catchup-source' :re.compile('catchup-source=\"(.*?)\"' , re.IGNORECASE).search(line),
'catchup-days' :re.compile('catchup-days=\"(.*?)\"' , re.IGNORECASE).search(line),
'catchup-correction':re.compile('catchup-correction=\"(.*?)\"' , re.IGNORECASE).search(line),
'provider' :re.compile('provider=\"(.*?)\"' , re.IGNORECASE).search(line),
'provider-type' :re.compile('provider-type=\"(.*?)\"' , re.IGNORECASE).search(line),
'provider-logo' :re.compile('provider-logo=\"(.*?)\"' , re.IGNORECASE).search(line),
'provider-countries':re.compile('provider-countries=\"(.*?)\"' , re.IGNORECASE).search(line),
'provider-languages':re.compile('provider-languages=\"(.*?)\"' , re.IGNORECASE).search(line),
'media' :re.compile('media=\"(.*?)\"' , re.IGNORECASE).search(line),
'media-dir' :re.compile('media-dir=\"(.*?)\"' , re.IGNORECASE).search(line),
'media-size' :re.compile('media-size=\"(.*?)\"' , re.IGNORECASE).search(line),
'realtime' :re.compile('realtime=\"(.*?)\"' , re.IGNORECASE).search(line)}
if match['id'].group(1) in filter:
self.log('_load, filtering duplicate %s'%(match['id'].group(1)))
continue
filter.append(match['id'].group(1)) #filter dups, todo find where dups originate from.
mitem = self.getMitem()
mitem.update({'number' :chCount,
'logo' :LOGO,
'catchup':''}) #set default parameters
for key, value in list(match.items()):
if value is None:
if data.get(key,None) is not None:
self.log('_load, using #EXTM3U "%s" value for #EXTINF'%(key))
value = data[key] #no local EXTINF value found; use global EXTM3U if applicable.
else: continue
if value.group(1) is None:
continue
elif key == 'logo':
mitem[key] = value.group(1)
elif key == 'number':
try: mitem[key] = int(value.group(1))
except: mitem[key] = float(value.group(1))#todo why was this needed?
elif key == 'group':
mitem[key] = [_f for _f in sorted(list(set((value.group(1)).split(';')))) if _f]
elif key in ['radio','favorite','realtime','media']:
mitem[key] = (value.group(1)).lower() == 'true'
else:
mitem[key] = value.group(1)
for nidx in range(idx+1,len(lines)):
try:
nline = lines[nidx].rstrip()
if nline.startswith('#EXTINF:'): break
elif nline.startswith('#EXTGRP'):
grop = re.compile('^#EXTGRP:(.*)$', re.IGNORECASE).search(nline)
if grop is not None:
mitem['group'].append(grop.group(1).split(';'))
mitem['group'] = sorted(set(mitem['group']))
elif nline.startswith('#KODIPROP:'):
prop = re.compile('^#KODIPROP:(.*)$', re.IGNORECASE).search(nline)
if prop is not None: mitem.setdefault('kodiprops',[]).append(prop.group(1))
elif nline.startswith('#EXTVLCOPT'):
copt = re.compile('^#EXTVLCOPT:(.*)$', re.IGNORECASE).search(nline)
if copt is not None: mitem.setdefault('extvlcopt',[]).append(copt.group(1))
elif nline.startswith('#EXT-X-PLAYLIST-TYPE'):
xplay = re.compile('^#EXT-X-PLAYLIST-TYPE:(.*)$', re.IGNORECASE).search(nline)
if xplay is not None: mitem['x-playlist-type'] = xplay.group(1)
elif nline.startswith('##'): continue
elif not nline: continue
else: mitem['url'] = nline
except Exception as e: self.log('_load, error parsing m3u! %s'%(e))
#Fill missing with similar parameters.
mitem['name'] = (mitem.get('name') or mitem.get('label') or '')
mitem['label'] = (mitem.get('label') or mitem.get('name') or '')
mitem['favorite'] = (mitem.get('favorite') or False)
#Set Fav. based on group value.
if LANGUAGE(32019) in mitem['group'] and not mitem['favorite']:
mitem['favorite'] = True
#Core m3u parameters missing, ignore entry.
if not mitem.get('id') or not mitem.get('name') or not mitem.get('number'):
self.log('_load, SKIPPED MISSING META m3u item = %s'%mitem)
continue
self.log('_load, m3u item = %s'%mitem)
yield mitem
def _save(self, file=M3UFLEPATH):
with FileLock():
fle = FileAccess.open(file, 'w')
fle.write('%s\n'%(self.M3UDATA['data']))
opts = list(self.getMitem().keys())
mins = [opts.pop(opts.index(key)) for key in list(M3U_MIN.keys()) if key in opts] #min required m3u entries.
line = '#EXTINF:-1 tvg-chno="%s" tvg-id="%s" tvg-name="%s" tvg-logo="%s" group-title="%s" radio="%s" catchup="%s" %s,%s\n'
self.M3UDATA['stations'] = self.sortStations(self.M3UDATA.get('stations',[]))
self.M3UDATA['recordings'] = self.sortStations(self.M3UDATA.get('recordings',[]), key='name')
self.log('_save, saving %s stations and %s recordings to %s'%(len(self.M3UDATA['stations']),len(self.M3UDATA['recordings']),file))
for station in (self.M3UDATA['recordings'] + self.M3UDATA['stations']):
optional = ''
xplaylist = ''
kodiprops = {}
extvlcopt = {}
# write optional m3u parameters.
if 'kodiprops' in station: kodiprops = station.pop('kodiprops')
if 'extvlcopt' in station: extvlcopt = station.pop('extvlcopt')
if 'x-playlist-type' in station: xplaylist = station.pop('x-playlist-type')
for key, value in list(station.items()):
if key in opts and str(value):
optional += '%s="%s" '%(key,value)
fle.write(line%(station['number'],
station['id'],
station['name'],
station['logo'],
';'.join(station['group']),
station['radio'],
station['catchup'],
optional,
station['label']))
if kodiprops: fle.write('%s\n'%('\n'.join(['#KODIPROP:%s'%(prop) for prop in kodiprops])))
if extvlcopt: fle.write('%s\n'%('\n'.join(['#EXTVLCOPT:%s'%(prop) for prop in extvlcopt])))
if xplaylist: fle.write('%s\n'%('#EXT-X-PLAYLIST-TYPE:%s'%(xplaylist)))
fle.write('%s\n'%(station['url']))
fle.close()
return self._reload()
def _reload(self):
self.log('_reload')
self.__init__()
return True
def _verify(self, stations=[], recordings=[], chkPath=SETTINGS.getSettingBool('Clean_Recordings')):
if stations: #remove abandoned m3u entries; Stations that are not found in the channel list
channels = Channels().getChannels()
stations = [station for station in stations for channel in channels if channel.get('id') == station.get('id',str(random.random()))]
self.log('_verify, stations = %s'%(len(stations)))
return stations
elif recordings:#remove recordings that no longer exists on disk
if chkPath: recordings = [recording for recording in recordings if hasFile(decodeString(dict(urllib.parse.parse_qsl(recording.get('url',''))).get('vid').replace('.pvr','')))]
else: recordings = [recording for recording in recordings if recording.get('media',False)]
self.log('_verify, recordings = %s, chkPath = %s'%(len(recordings),chkPath))
return recordings
return []
def cleanSelf(self, items, key='id', slug='@%s'%(slugify(ADDON_NAME))): # remove m3u imports (Non PseudoTV Live)
if not slug: return items
stations = self.sortStations(self._verify(stations=[station for station in items if station.get(key,'').endswith(slug) and not station.get('media',False)]))
recordings = self.sortStations(self._verify(recordings=[recording for recording in items if recording.get(key,'').endswith(slug) and recording.get('media',False)]), key='name')
self.log('cleanSelf, slug = %s, key = %s: returning: stations = %s, recordings = %s'%(slug,key,len(stations),len(recordings)))
return stations, recordings
def sortStations(self, stations, key='number'):
try: return sorted(stations, key=itemgetter(key))
except: return stations
def getM3U(self):
return self.M3UDATA
def getMitem(self):
return M3U_TEMP.copy()
def getTZShift(self):
self.log('getTZShift')
return ((time.mktime(time.localtime()) - time.mktime(time.gmtime())) / 60 / 60)
def getStations(self):
stations = self.sortStations(self.M3UDATA.get('stations',[]))
self.log('getStations, stations = %s'%(len(stations)))
return stations
def getRecordings(self):
recordings = self.sortStations(self.M3UDATA.get('recordings',[]), key='name')
self.log('getRecordings, recordings = %s'%(len(recordings)))
return recordings
def findStation(self, citem):
for idx, eitem in enumerate(self.M3UDATA.get('stations',[])):
if (citem.get('id',str(random.random())) == eitem.get('id') or citem.get('url',str(random.random())).lower() == eitem.get('url','').lower()):
self.log('findStation, found eitem = %s'%(eitem))
return idx, eitem
return None, {}
def findRecording(self, ritem):
for idx, eitem in enumerate(self.M3UDATA.get('recordings',[])):
if (ritem.get('id',str(random.random())) == eitem.get('id')) or (ritem.get('label',str(random.random())).lower() == eitem.get('label','').lower()) or (ritem.get('path',str(random.random())).endswith('%s.pvr'%(eitem.get('name')))):
self.log('findRecording, found eitem = %s'%(eitem))
return idx, eitem
return None, {}
def getStationItem(self, sitem):
if sitem.get('resume',False):
sitem['url'] = RESUME_URL.format(addon=ADDON_ID,name=quoteString(sitem['name']),chid=quoteString(sitem['id']))
elif sitem['catchup']:
sitem['catchup-source'] = BROADCAST_URL.format(addon=ADDON_ID,name=quoteString(sitem['name']),chid=quoteString(sitem['id']),vid='{catchup-id}')
sitem['url'] = LIVE_URL.format(addon=ADDON_ID,name=quoteString(sitem['name']),chid=quoteString(sitem['id']),vid='{catchup-id}',now='{lutc}',start='{utc}',duration='{duration}',stop='{utcend}')
elif sitem['radio']: sitem['url'] = RADIO_URL.format(addon=ADDON_ID,name=quoteString(sitem['name']),chid=quoteString(sitem['id']),radio=str(sitem['radio']),vid='{catchup-id}')
else: sitem['url'] = TV_URL.format(addon=ADDON_ID,name=quoteString(sitem['name']),chid=quoteString(sitem['id']))
return sitem
def getRecordItem(self, fitem, seek=0):
if seek <= 0: group = LANGUAGE(30119)
else: group = LANGUAGE(30152)
ritem = self.getMitem()
ritem['provider'] = '%s (%s)'%(ADDON_NAME,SETTINGS.getFriendlyName())
ritem['provider-type'] = 'addon'
ritem['provider-logo'] = HOST_LOGO
ritem['label'] = (fitem.get('showlabel') or '%s%s'%(fitem.get('label',''),' - %s'%(fitem.get('episodelabel','')) if fitem.get('episodelabel','') else ''))
ritem['name'] = ritem['label']
ritem['number'] = random.Random(str(fitem.get('id',1))).random()
ritem['logo'] = cleanImage((getThumb(fitem,opt=EPG_ARTWORK) or {0:FANART,1:COLOR_LOGO}[EPG_ARTWORK]))
ritem['media'] = True
ritem['media-size'] = str(fitem.get('size',0))
ritem['media-dir'] = ''#todo optional add parent directory via user prompt?
ritem['group'] = ['%s (%s)'%(group,ADDON_NAME)]
ritem['id'] = getRecordID(ritem['name'], (fitem.get('originalfile') or fitem.get('file','')), ritem['number'])
ritem['url'] = DVR_URL.format(addon=ADDON_ID,title=quoteString(ritem['label']),chid=quoteString(ritem['id']),vid=quoteString(encodeString((fitem.get('originalfile') or fitem.get('file','')))),seek=seek,duration=fitem.get('duration',0))#fitem.get('catchup-id','')
return ritem
def addStation(self, citem):
idx, line = self.findStation(citem)
self.log('addStation,\nchannel item = %s\nfound existing = %s'%(citem,line))
mitem = self.getMitem()
mitem.update(citem)
mitem['label'] = citem['name'] #todo channel manager opt to change channel 'label' leaving 'name' static for channelid purposes
mitem['logo'] = citem['logo']
mitem['realtime'] = False
mitem['provider'] = '%s (%s)'%(ADDON_NAME,SETTINGS.getFriendlyName())
mitem['provider-type'] = 'addon'
mitem['provider-logo'] = HOST_LOGO
if not idx is None: self.M3UDATA['stations'].pop(idx)
self.M3UDATA.get('stations',[]).append(mitem)
self.log('addStation, channels = %s'%(len(self.M3UDATA.get('stations',[]))))
return True
def addRecording(self, ritem):
# https://github.com/kodi-pvr/pvr.iptvsimple/blob/Omega/README.md#media
idx, line = self.findRecording(ritem)
self.log('addRecording,\nrecording ritem = %s\nfound existing = %s'%(ritem,idx))
if not idx is None: self.M3UDATA['recordings'].pop(idx)
self.M3UDATA.get('recordings',[]).append(ritem)
return self._save()
def delStation(self, citem):
self.log('[%s] delStation'%(citem['id']))
idx, line = self.findStation(citem)
if not idx is None: self.M3UDATA['stations'].pop(idx)
return True
def delRecording(self, ritem):
self.log('[%s] delRecording'%((ritem.get('id') or ritem.get('label'))))
idx, line = self.findRecording(ritem)
if not idx is None:
self.M3UDATA['recordings'].pop(idx)
return self._save()
def importM3U(self, file, filters={}, multiplier=1):
self.log('importM3U, file = %s, filters = %s, multiplier = %s'%(file,filters,multiplier))
try:
importChannels = []
if file.startswith('http'):
url = file
file = os.path.join(TEMP_LOC,'%s'%(slugify(url)))
setURL(url,file)
stations = self._load(file)
for key, value in list(filters.items()):
if key == 'slug' and value:
importChannels.extend(self.cleanSelf(stations,'id',value)[0])
elif key == 'providers' and value:
for provider in value:
importChannels.extend(self.cleanSelf(stations,'provider',provider)[0])
#no filter found, import all stations.
if not importChannels: importChannels.extend(stations)
importChannels = self.sortStations(list(self.chkImport(importChannels,multiplier)))
self.log('importM3U, found import stations = %s'%(len(importChannels)))
self.M3UDATA.get('stations',[]).extend(importChannels)
except Exception as e: self.log("importM3U, failed! %s"%(e), xbmc.LOGERROR)
return importChannels
def chkImport(self, stations, multiplier=1):
def roundup(x):
return x if x % 1000 == 0 else x + 1000 - x % 1000
def frange(start, stop, step):
while not MONITOR().abortRequested() and start < stop:
yield float(start)
start += decimal.Decimal(step)
stations = self.sortStations(stations)
chstart = roundup((CHANNEL_LIMIT * len(CHAN_TYPES)+1))
chmin = int(chstart + (multiplier*1000))
chmax = int(chmin + (CHANNEL_LIMIT))
chrange = list(frange(chmin,chmax,0.1))
leftovers = []
self.log('chkImport, stations = %s, multiplier = %s, chstart = %s, chmin = %s, chmax = %s'%(len(stations),multiplier,chstart,chmin,chmax))
## check tvg-chno for conflict, use multiplier to modify org chnum.
for mitem in stations:
if len(chrange) == 0:
self.log('chkImport, reached max import')
break
elif mitem['number'] < CHANNEL_LIMIT:
newnumber = (chmin+mitem['number'])
if newnumber in chrange:
chrange.remove(newnumber)
mitem['number'] = newnumber
yield mitem
else: leftovers.append(mitem)
else: leftovers.append(mitem)
for mitem in leftovers:
if len(chrange) == 0:
self.log('chkImport, reached max import')
break
else:
mitem['number'] = chrange.pop(0)
yield mitem

View File

@@ -0,0 +1,974 @@
# Copyright (C) 2024 Lunatixz
# This file is part of PseudoTV Live.
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from cache import Cache
from channels import Channels
from jsonrpc import JSONRPC
from rules import RulesList
from resources import Resources
from xsp import XSP
from infotagger.listitem import ListItemInfoTag
# Actions
ACTION_MOVE_LEFT = 1
ACTION_MOVE_RIGHT = 2
ACTION_MOVE_UP = 3
ACTION_MOVE_DOWN = 4
ACTION_SELECT_ITEM = 7
ACTION_INVALID = 999
ACTION_SHOW_INFO = [11,24,401]
ACTION_PREVIOUS_MENU = [92, 10,110,521] #+ [9, 92, 216, 247, 257, 275, 61467, 61448]
class Manager(xbmcgui.WindowXMLDialog):
monitor = MONITOR()
focusIndex = -1
newChannels = []
def __init__(self, *args, **kwargs):
xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs)
def __get1stChannel(channelList):
for channel in channelList:
if not channel.get('id'): return channel.get('number')
return 1
def __findChannel(chnum, retitem=False, channels=[]):
for idx, channel in enumerate(channels):
if channel.get('number') == (chnum or 1):
if retitem: return channel
else: return idx
with BUILTIN.busy_dialog():
self.server = {}
self.lockAutotune = True
self.madeChanges = False
self.madeItemchange = False
self.lastActionTime = time.time()
self.cntrlStates = {}
self.showingList = True
self.startChannel = kwargs.get('channel',-1)
self.openChannel = kwargs.get('open')
self.cache = SETTINGS.cache
self.channels = Channels()
self.rule = RulesList()
self.jsonRPC = JSONRPC()
self.resource = Resources()
self.host = PROPERTIES.getRemoteHost()
self.friendly = SETTINGS.getFriendlyName()
self.newChannel = self.channels.getTemplate()
self.eChannels = self.loadChannels(SETTINGS.getSetting('Default_Channels'))
try:
if self.eChannels is None: raise Exception("No Channels Found!")
else:
self.channelList = self.channels.sortChannels(self.createChannelList(self.buildArray(), self.eChannels))
self.newChannels = self.channelList.copy()
if self.startChannel == -1: self.startChannel = __get1stChannel(self.channelList)
if self.startChannel <= CHANNEL_LIMIT: self.focusIndex = (self.startChannel - 1) #Convert from Channel number to array index
else: self.focusIndex = __findChannel(self.startChannel,channels=self.channelList)
if self.openChannel: self.openChannel = self.channelList[self.focusIndex]
self.log('Manager, startChannel = %s, focusIndex = %s, openChannel = %s'%(self.startChannel, self.focusIndex, self.openChannel))
if kwargs.get('start',True): self.doModal()
except Exception as e:
self.log('Manager failed! %s'%(e), xbmc.LOGERROR)
self.closeManager()
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def onInit(self):
try:
self.focusItems = dict()
self.spinner = self.getControl(4)
self.chanList = self.getControl(5)
self.itemList = self.getControl(6)
self.right_button1 = self.getControl(9001)
self.right_button2 = self.getControl(9002)
self.right_button3 = self.getControl(9003)
self.right_button4 = self.getControl(9004)
self.fillChanList(self.newChannels,focus=self.focusIndex,channel=self.openChannel)
except Exception as e:
log("onInit, failed! %s"%(e), xbmc.LOGERROR)
self.closeManager()
def getServers(self):
from multiroom import Multiroom
return Multiroom().getDiscovery()
def loadChannels(self, name=''):
self.log('loadChannels, name = %s'%(name))
channels = self.channels.getChannels()
if name == self.friendly: return channels
elif name == LANGUAGE(30022):#Auto
if len(channels) > 0: return channels
else: return self.loadChannels('Ask')
elif name == 'Ask':
def __buildItem(servers, server):
if server.get('online',False):
return self.buildListItem(server.get('name'),'%s - %s: Channels (%s)'%(LANGUAGE(32211)%({True:'green',False:'red'}[server.get('online',False)],{True:LANGUAGE(32158),False:LANGUAGE(32253)}[server.get('online',False)]),server.get('host'),len(server.get('channels',[]))),icon=DUMMY_ICON.format(text=str(servers.index(server)+1)))
servers = self.getServers()
lizlst = poolit(__buildItem)(*(list(servers.values()),list(servers.values())))
lizlst.insert(0,self.buildListItem(self.friendly,'%s - %s: Channels (%s)'%('[B]Local[/B]',self.host,len(channels)),icon=ICON))
select = DIALOG.selectDialog(lizlst, LANGUAGE(30173), None, True, SELECT_DELAY, False)
if not select is None: return self.loadChannels(lizlst[select].getLabel())
else: return
elif name:
self.server = self.getServers().get(name,{})
return self.server.get('channels',[])
return channels
@cacheit(json_data=True)
def buildArray(self):
self.log('buildArray') # Create blank array of citem templates.
def __create(idx):
newChannel = self.newChannel.copy()
newChannel['number'] = idx + 1
return newChannel
return poolit(__create)(list(range(CHANNEL_LIMIT)))
def createChannelList(self, channelArray, channelList):
self.log('createChannelList') # Fill blank array with citems from channels.json
def __update(item):
try: channelArray[item["number"]-1].update(item) #CUSTOM
except: channelArray.append(item) #AUTOTUNE
checksum = getMD5(dumpJSON(channelList))
cacheName = 'createChannelList.%s'%(checksum)
cacheResponse = self.cache.get(cacheName, checksum=checksum, json_data=True)
if not cacheResponse:
poolit(__update)(channelList)
cacheResponse = self.cache.set(cacheName, channelArray, checksum=checksum, json_data=True)
return cacheResponse
def buildListItem(self, label: str="", label2: str="", icon: str="", paths: list=[], items: dict={}):
if not icon: icon = (items.get('citem',{}).get('logo') or COLOR_LOGO)
if not paths: paths = (items.get('citem',{}).get("path") or [])
return LISTITEMS.buildMenuListItem(label, label2, icon, url='|'.join(paths), props=items)
def fillChanList(self, channelList, refresh=False, focus=None, channel=None):
self.log('fillChanList, focus = %s, channel = %s'%(focus,channel))
def __buildItem(citem):
isPredefined = citem["number"] > CHANNEL_LIMIT
isFavorite = citem.get('favorite',False)
isRadio = citem.get('radio',False)
isLocked = isPredefined #todo parse channel lock rule
channelColor = COLOR_UNAVAILABLE_CHANNEL
labelColor = COLOR_UNAVAILABLE_CHANNEL
if citem.get("path"):
if isPredefined: channelColor = COLOR_LOCKED_CHANNEL
else:
labelColor = COLOR_AVAILABLE_CHANNEL
if isLocked: channelColor = COLOR_LOCKED_CHANNEL
elif isFavorite: channelColor = COLOR_FAVORITE_CHANNEL
elif isRadio: channelColor = COLOR_RADIO_CHANNEL
else: channelColor = COLOR_AVAILABLE_CHANNEL
return self.buildListItem('[COLOR=%s][B]%s|[/COLOR][/B]'%(channelColor,citem["number"]),'[COLOR=%s]%s[/COLOR]'%(labelColor,citem.get("name",'')),items={'citem':citem,'chname':citem["name"],'chnum':'%i'%(citem["number"]),'radio':citem.get('radio',False),'description':LANGUAGE(32169)%(citem["number"],self.server.get('name',self.friendly))})
self.togglechanList(reset=refresh)
with self.toggleSpinner():
listitems = poolit(__buildItem)(channelList)
self.chanList.addItems(listitems)
if focus is None: self.chanList.selectItem(self.setFocusPOS(listitems))
else: self.chanList.selectItem(focus)
self.setFocus(self.chanList)
if channel: self.buildChannelItem(channel)
@contextmanager
def toggleSpinner(self, state=True, allow=True):
if allow:
self.setVisibility(self.spinner,state)
try: yield
finally: self.setVisibility(self.spinner,False)
else: yield
def togglechanList(self, state=True, focus=0, reset=False):
self.log('togglechanList, state = %s, focus = %s, reset = %s'%(state,focus,reset))
with self.toggleSpinner():
if state: # channellist
if reset:
self.setVisibility(self.chanList,False)
self.chanList.reset()
self.setVisibility(self.itemList,False)
self.setVisibility(self.chanList,True)
self.setFocus(self.chanList)
self.chanList.selectItem(focus)
if self.madeChanges:
self.setLabels(self.right_button1,LANGUAGE(32059))#Save
self.setLabels(self.right_button2,LANGUAGE(32060))#Cancel
self.setLabels(self.right_button3,LANGUAGE(32136))#Move
self.setLabels(self.right_button4,LANGUAGE(32061))#Delete
self.setEnableCondition(self.right_button1,'[!String.IsEmpty(Container(5).ListItem(Container(5).Position).Property(chnum))]')
self.setEnableCondition(self.right_button2,'[!String.IsEmpty(Container(5).ListItem(Container(5).Position).Property(chnum))]')
else:
self.setLabels(self.right_button1,LANGUAGE(32062))#Close
self.setLabels(self.right_button2,LANGUAGE(32235))#Preview
self.setLabels(self.right_button3,LANGUAGE(32136))#Move
self.setLabels(self.right_button4,LANGUAGE(32061))#Delete
self.setEnableCondition(self.right_button1,'[!String.IsEmpty(Container(5).ListItem(Container(5).Position).Property(chnum))]')
self.setEnableCondition(self.right_button2,'[!String.IsEmpty(Container(5).ListItem(Container(5).Position).Path) + String.IsEqual(Container(5).ListItem(Container(5).Position).Property(radio),False)]')
self.setFocus(self.right_button1)
self.setEnableCondition(self.right_button3,'[!String.IsEmpty(Container(5).ListItem(Container(5).Position).Path)]')# + Integer.IsLessOrEqual(Container(5).ListItem(Container(5).Position).Property(chnum),CHANNEL_LIMIT)]')
self.setEnableCondition(self.right_button4,'[!String.IsEmpty(Container(5).ListItem(Container(5).Position).Path)]')# + Integer.IsLessOrEqual(Container(5).ListItem(Container(5).Position).Property(chnum),CHANNEL_LIMIT)]')
else: # channelitems
self.itemList.reset()
self.setVisibility(self.chanList,False)
self.setVisibility(self.itemList,True)
self.itemList.selectItem(focus)
self.setFocus(self.itemList)
if self.madeItemchange:
self.setLabels(self.right_button1,LANGUAGE(32240))#Confirm
self.setLabels(self.right_button2,LANGUAGE(32060))#Cancel
self.setEnableCondition(self.right_button1,'[!String.IsEmpty(Container(6).ListItem(Container(6).Position).Label) + !String.IsEmpty(Container(6).ListItem(Container(6).Position).Path)]')
self.setEnableCondition(self.right_button2,'[!String.IsEmpty(Container(6).ListItem(Container(6).Position).Property(chnum))]')
else:
self.setLabels(self.right_button1,LANGUAGE(32062))#Close
self.setLabels(self.right_button2,LANGUAGE(32060))#Cancel
self.setEnableCondition(self.right_button1,'[!String.IsEmpty(Container(6).ListItem(Container(6).Position).Property(chnum))]')
self.setEnableCondition(self.right_button2,'[!String.IsEmpty(Container(6).ListItem(Container(6).Position).Path)]')
self.setLabels(self.right_button3,LANGUAGE(32235))#Preview
self.setLabels(self.right_button4,LANGUAGE(32239))#Clear
self.setEnableCondition(self.right_button3,'[!String.IsEmpty(Container(6).ListItem(Container(6).Position).Path) + String.IsEqual(Container(6).ListItem(Container(6).Position).Property(radio),False)]')
self.setEnableCondition(self.right_button4,'[!String.IsEmpty(Container(6).ListItem(Container(6).Position).Path)]')
def setFocusPOS(self, listitems, chnum=None, ignore=True):
for idx, listitem in enumerate(listitems):
chnumber = int(cleanLabel(listitem.getLabel()).strip('|'))
if ignore and chnumber > CHANNEL_LIMIT: continue
elif chnum is not None and chnum == chnumber: return idx
elif chnum is None and cleanLabel(listitem.getLabel2()): return idx
return 0
def getRuleAbbr(self, citem, myId, optionindex):
value = citem.get('rules',{}).get(str(myId),{}).get('values',{}).get(str(optionindex))
self.log('getRuleAbbr, id = %s, myId = %s, optionindex = %s, optionvalue = %s'%(citem.get('id',-1),myId,optionindex,value))
return value
def getLogoColor(self, citem):
self.log('getLogoColor, id = %s'%(citem.get('id',-1)))
if (citem.get('logo') and citem.get('name')) is None: return 'FFFFFFFF'
elif citem.get('rules',{}).get("1"):
if (self.getRuleAbbr(citem,1,4) or self.resource.isMono(citem['logo'])):
return self.getRuleAbbr(citem,1,3)
return SETTINGS.getSetting('ChannelBug_Color')
def buildChannelItem(self, citem: dict={}, focuskey: str='path'):
self.log('buildChannelItem, id = %s, focuskey = %s'%(citem.get('id'),focuskey))
def __buildItem(key):
key = key.lower()
value = citem.get(key,' ')
if key in ["number","type","logo","id","catchup"]: return # keys to ignore, internal use only.
elif isinstance(value,(list,dict)):
if key == "group" : value = ('|'.join(sorted(set(value))) or LANGUAGE(30127))
elif key == "path" : value = '|'.join(value)
elif key == "rules" : value = '|'.join([rule.name for key, rule in list(self.rule.loadRules([citem]).get(citem['id'],{}).items())])#todo load rule names
elif not isinstance(value,str): value = str(value)
elif not value: value = ' '
return self.buildListItem(LABEL.get(key,' '),value,items={'key':key,'value':value,'citem':citem,'chname':citem["name"],'chnum':'%i'%(citem["number"]),'radio':citem.get('radio',False),'description':DESC.get(key,''),'colorDiffuse':self.getLogoColor(citem)})
self.togglechanList(False)
with self.toggleSpinner():
LABEL = {'name' : LANGUAGE(32092),
'path' : LANGUAGE(32093),
'group' : LANGUAGE(32094),
'rules' : LANGUAGE(32095),
'radio' : LANGUAGE(32091),
'favorite': LANGUAGE(32090)}
DESC = {'name' : LANGUAGE(32085),
'path' : LANGUAGE(32086),
'group' : LANGUAGE(32087),
'rules' : LANGUAGE(32088),
'radio' : LANGUAGE(32084),
'favorite': LANGUAGE(32083)}
listitems = poolit(__buildItem)(list(self.newChannel.keys()))
self.itemList.addItems(listitems)
self.itemList.selectItem([idx for idx, liz in enumerate(listitems) if liz.getProperty('key')== focuskey][0])
self.setFocus(self.itemList)
def itemInput(self, channelListItem=xbmcgui.ListItem()):
def __getName(citem: dict={}, name: str=''):
return DIALOG.inputDialog(message=LANGUAGE(32079),default=name), citem
def __getPath(citem: dict={}, paths: list=[]):
return self.getPaths(citem, paths)
def __getGroups(citem: dict={}, groups: list=[]):
groups = list([_f for _f in groups if _f])
ngroups = sorted([_f for _f in set(SETTINGS.getSetting('User_Groups').split('|') + GROUP_TYPES + groups) if _f])
ngroups.insert(0, '-%s'%(LANGUAGE(30064)))
selects = DIALOG.selectDialog(ngroups,header=LANGUAGE(32081),preselect=findItemsInLST(ngroups,groups),useDetails=False)
if 0 in selects:
SETTINGS.setSetting('User_Groups',DIALOG.inputDialog(LANGUAGE(32044), default=SETTINGS.getSetting('User_Groups')))
return __getGroups(citem, groups)
elif len(ngroups) > 0: groups = [ngroups[idx] for idx in selects]
if not groups: groups = [LANGUAGE(30127)]
return groups, citem
def __getRule(citem: dict={}, rules: dict={}):
return self.getRules(citem, rules)
def __getBool(citem: dict={}, state: bool=False):
return not bool(state), citem
key = channelListItem.getProperty('key')
value = channelListItem.getProperty('value')
citem = loadJSON(channelListItem.getProperty('citem'))
self.log('itemInput, In value = %s, key = %s\ncitem = %s'%(value,key,citem))
KEY_INPUT = {"name" : {'func':__getName , 'kwargs':{'citem':citem, 'name' :citem.get('name','')}},
"path" : {'func':__getPath , 'kwargs':{'citem':citem, 'paths' :citem.get('path',[])}},
"group" : {'func':__getGroups, 'kwargs':{'citem':citem, 'groups':citem.get('group',[])}},
"rules" : {'func':__getRule , 'kwargs':{'citem':citem, 'rules' :self.rule.loadRules([citem],incRez=False).get(citem['id'],{})}},
"radio" : {'func':__getBool , 'kwargs':{'citem':citem, 'state' :citem.get('radio',False)}},
"favorite" : {'func':__getBool , 'kwargs':{'citem':citem, 'state' :citem.get('favorite',False)}}}
action = KEY_INPUT.get(key)
retval, citem = action['func'](*action.get('args',()),**action.get('kwargs',{}))
retval, citem = self.validateInputs(key,retval,citem)
if not retval is None:
self.madeItemchange = True
if key in list(self.newChannel.keys()): citem[key] = retval
self.log('itemInput, Out value = %s, key = %s\ncitem = %s'%(retval,key,citem))
return citem
def getPaths(self, citem: dict={}, paths: list=[]):
select = -1
epaths = paths.copy()
pathLST = list([_f for _f in paths if _f])
lastOPT = None
if not citem.get('radio',False) and isRadio({'path':paths}): citem['radio'] = True #set radio on music paths
if citem.get('radio',False): excLST = [10,12,21,22]
else: excLST = [11,13,21]
while not self.monitor.abortRequested() and not select is None:
with self.toggleSpinner():
npath = None
lizLST = [self.buildListItem('%s|'%(idx+1),path,paths=[path],icon=DUMMY_ICON.format(text=str(idx+1)),items={'citem':citem,'idx':idx+1}) for idx, path in enumerate(pathLST) if path]
lizLST.insert(0,self.buildListItem('[COLOR=white][B]%s[/B][/COLOR]'%(LANGUAGE(32100)),LANGUAGE(33113),icon=ICON,items={'key':'add','citem':citem,'idx':0}))
if len(pathLST) > 0 and epaths != pathLST: lizLST.insert(1,self.buildListItem('[COLOR=white][B]%s[/B][/COLOR]'%(LANGUAGE(32101)),LANGUAGE(33114),icon=ICON,items={'key':'save','citem':citem}))
select = DIALOG.selectDialog(lizLST, header=LANGUAGE(32086), preselect=lastOPT, multi=False)
with self.toggleSpinner():
if not select is None:
key, path = lizLST[select].getProperty('key'), lizLST[select].getPath()
try: lastOPT = int(lizLST[select].getProperty('idx'))
except: lastOPT = None
if key == 'add':
retval = DIALOG.browseSources(heading=LANGUAGE(32080), exclude=excLST, monitor=True)
if not retval is None:
npath, citem = self.validatePaths(retval,citem)
if npath: pathLST.append(npath)
elif key == 'save':
paths = pathLST
break
elif path in pathLST:
retval = DIALOG.yesnoDialog(LANGUAGE(32102), customlabel=LANGUAGE(32103))
if retval in [1,2]: pathLST.pop(pathLST.index(path))
if retval == 2:
with self.toggleSpinner():
npath, citem = self.validatePaths(DIALOG.browseSources(heading=LANGUAGE(32080), default=path, monitor=True, exclude=excLST), citem)
pathLST.append(npath)
self.log('getPaths, paths = %s'%(paths))
return paths, citem
def getRules(self, citem: dict={}, rules: dict={}):
if citem.get('id') is None or len(citem.get('path',[])) == 0: DIALOG.notificationDialog(LANGUAGE(32071))
else:
select = -1
erules = rules.copy()
ruleLST = rules.copy()
lastIDX = None
lastXID = None
while not self.monitor.abortRequested() and not select is None:
with self.toggleSpinner():
nrule = None
crules = self.rule.loadRules([citem],append=True,incRez=False).get(citem['id'],{}) #all rule instances w/ channel rules
arules = [rule for key, rule in list(crules.items()) if not ruleLST.get(key)] #all unused rule instances
lizLST = [self.buildListItem(rule.name,rule.getTitle(),icon=DUMMY_ICON.format(text=str(rule.myId)),items={'myId':rule.myId,'citem':citem,'idx':list(ruleLST.keys()).index(key)+1}) for key, rule in list(ruleLST.items()) if rule.myId]
lizLST.insert(0,self.buildListItem('[COLOR=white][B]%s[/B][/COLOR]'%(LANGUAGE(32173)),"",icon=ICON,items={'key':'add' ,'citem':citem,'idx':0}))
if len(ruleLST) > 0 and erules != ruleLST: lizLST.insert(1,self.buildListItem('[COLOR=white][B]%s[/B][/COLOR]'%(LANGUAGE(32174)),"",icon=ICON,items={'key':'save','citem':citem}))
select = DIALOG.selectDialog(lizLST, header=LANGUAGE(32095), preselect=lastIDX, multi=False)
if not select is None:
key, myId = lizLST[select].getProperty('key'), int(lizLST[select].getProperty('myId') or '-1')
try: lastIDX = int(lizLST[select].getProperty('idx'))
except: lastIDX = None
if key == 'add':
with self.toggleSpinner():
lizLST = [self.buildListItem(rule.name,rule.description,icon=DUMMY_ICON.format(text=str(rule.myId)),items={'idx':idx,'myId':rule.myId,'citem':citem}) for idx, rule in enumerate(arules) if rule.myId]
select = DIALOG.selectDialog(lizLST, header=LANGUAGE(32072), preselect=lastXID, multi=False)
try: lastXID = int(lizLST[select].getProperty('idx'))
except: lastXID = -1
nrule, citem = self.getRule(citem, arules[lastXID])
if not nrule is None: ruleLST.update({str(nrule.myId):nrule})
elif key == 'save':
rules = ruleLST
break
elif ruleLST.get(str(myId)):
retval = DIALOG.yesnoDialog(LANGUAGE(32175), customlabel=LANGUAGE(32176))
if retval in [1,2]: ruleLST.pop(str(myId))
if retval == 2:
nrule, citem = self.getRule(citem, crules.get(str(myId),{}))
if not nrule is None: ruleLST.update({str(nrule.myId):nrule})
# elif not ruleLST.get(str(myId)):
# nrule, citem = self.getRule(citem, crules.get(str(myId),{}))
# if not nrule is None: ruleLST.update({str(nrule.myId):nrule})
self.log('getRules, rules = %s'%(len(rules)))
return self.rule.dumpRules(rules), citem
def getRule(self, citem={}, rule={}):
self.log('getRule, name = %s'%(rule.name))
if rule.exclude and True in list(set([True for p in citem.get('path',[]) if p.endswith('.xsp')])): return DIALOG.notificationDialog(LANGUAGE(32178))
else:
select = -1
while not self.monitor.abortRequested() and not select is None:
with self.toggleSpinner():
lizLST = [self.buildListItem('%s = %s'%(rule.optionLabels[idx],rule.optionValues[idx]),rule.optionDescriptions[idx],DUMMY_ICON.format(text=str(idx+1)),[str(rule.myId)],{'value':optionValue,'idx':idx,'myId':rule.myId,'citem':citem}) for idx, optionValue in enumerate(rule.optionValues)]
select = DIALOG.selectDialog(lizLST, header='%s %s - %s'%(LANGUAGE(32176),rule.myId,rule.name), multi=False)
if not select is None:
try: rule.onAction(int(lizLST[select].getProperty('idx') or "0"))
except Exception as e:
self.log("getRule, onAction failed! %s"%(e), xbmc.LOGERROR)
DIALOG.okDialog(LANGUAGE(32000))
return rule, citem
def setID(self, citem: dict={}) -> dict:
if not citem.get('id') and citem.get('name') and citem.get('path') and citem.get('number'):
citem['id'] = getChannelID(citem['name'], citem['path'], citem['number'])
self.log('setID, id = %s'%(citem['id']))
return citem
def setName(self, path, citem: dict={}) -> dict:
with self.toggleSpinner():
if citem.get('name'): return citem
elif path.strip('/').endswith(('.xml','.xsp')): citem['name'] = XSP().getName(path)
elif path.startswith(tuple(DB_TYPES+WEB_TYPES+VFS_TYPES)): citem['name'] = self.getMontiorList().getLabel()
else: citem['name'] = os.path.basename(os.path.dirname(path)).strip('/')
self.log('setName, id = %s, name = %s'%(citem['id'],citem['name']))
return citem
def setLogo(self, name=None, citem={}, force=False):
name = (name or citem.get('name'))
if name:
if force: logo = ''
else: logo = citem.get('logo')
if not logo or logo in [LOGO,COLOR_LOGO,ICON]:
with self.toggleSpinner():
citem['logo'] = self.resource.getLogo(citem,auto=True)
self.log('setLogo, id = %s, logo = %s, force = %s'%(citem.get('id'),citem.get('logo'),force))
return citem
def validateInputs(self, key, value, citem):
self.log('validateInputs, key = %s, value = %s'%(key,value))
def __validateName(name, citem):
if name and (len(name) > 1 or len(name) < 128):
citem['name'] = validString(name)
self.log('__validateName, name = %s'%(citem['name']))
return citem['name'], self.setLogo(name, citem, force=True)
return None, citem
def __validatePath(paths, citem):
if len(paths) > 0:
name, citem = __validateName(citem.get('name',''),self.setName(paths[0], citem))
self.log('__validatePath, name = %s, paths = %s'%(name,paths))
return paths, citem
return None, citem
def __validateGroup(groups, citem):
return groups, citem #todo check values
def __validateRules(rules, citem):
return rules, citem #todo check values
def __validateBool(state, citem):
if isinstance(state,bool): return state, citem
return None, citem
KEY_VALIDATION = {'name' :__validateName,
'path' :__validatePath,
'group' :__validateGroup,
'rules' :__validateRules,
'radio' :__validateBool,
'favorite':__validateBool}.get(key,None)
try:
with toggleSpinner():
retval, citem = KEY_VALIDATION(value,citem)
if retval is None:
DIALOG.notificationDialog(LANGUAGE(32077)%key.title())
return None , citem
return retval, self.setID(citem)
except Exception as e:
self.log("validateInputs, key = %s no action! %s"%(key,e))
return value, citem
def validatePaths(self, path, citem, spinner=True):
self.log('validatePaths, path = %s'%path)
def __set(path, citem):
citem = self.setName(path, citem)
return path, self.setLogo(citem.get('name'),citem)
def __seek(item, citem, cnt, dia, passed=False):
player = PLAYER()
if player.isPlaying(): return DIALOG.notificationDialog(LANGUAGE(30136))
# todo test seek for support disable via adv. rule if fails.
# todo set seeklock rule if seek == False
liz = xbmcgui.ListItem('Seek Test', path=item.get('file'))
liz.setProperty('startoffset', str(int(item.get('duration')//8)))
infoTag = ListItemInfoTag(liz, 'video')
infoTag.set_resume_point({'ResumeTime':int(item.get('duration')/4),'TotalTime':int(item.get('duration')*60)})
getTime = 0
waitTime = FIFTEEN
player.play(item.get('file'),liz)
while not self.monitor.abortRequested():
waitTime -= 1
self.log('validatePaths _seek, waiting (%s) to seek %s'%(waitTime, item.get('file')))
if self.monitor.waitForAbort(1.0) or waitTime < 1: break
elif not player.isPlaying(): continue
elif ((int(player.getTime()) > getTime) or BUILTIN.getInfoBool('SeekEnabled','Player')):
self.log('validatePaths _seek, found playable and seek-able file %s'%(item.get('file')))
passed = True
break
player.stop()
del player
if not passed:
retval = DIALOG.yesnoDialog(LANGUAGE(30202),customlabel='Try Again (%s)'%(cnt))
if retval == 1: passed = True
elif retval == 2: passed = None
self.log('validatePaths _seek, passed = %s'%(passed))
return passed
def __vfs(path, citem, valid=False):
if isRadio({'path':[path]}) or isMixed_XSP({'path':[path]}): return True #todo check mixed xsp.
with BUILTIN.busy_dialog():
items = self.jsonRPC.walkFileDirectory(path, 'music' if isRadio({'path':[path]}) else 'video', depth=5, retItem=True)
if len(items) > 0:
cnt = 3
msg = '%s %s, %s..\n%s'%(LANGUAGE(32098),'Path',LANGUAGE(32099),'%s...'%(str(path)))
dia = DIALOG.progressDialog(message=msg)
for idx, dir in enumerate(items):
if self.monitor.waitForAbort(0.0001): break
elif cnt <= 3 and cnt > 0:
item = random.choice(items.get(dir,[]))
msg = '%s %s...\n%s\n%s'%(LANGUAGE(32098),'Duration','%s...'%(dir),'%s...'%(item.get('file','')))
dia = DIALOG.progressDialog(int((idx*100)//len(items)), control=dia, message=msg)
item.update({'duration':self.jsonRPC.getDuration(item.get('file'), item, accurate=bool(SETTINGS.getSettingInt('Duration_Type')))})
if item.get('duration',0) == 0: continue
msg = '%s %s...\n%s\n%s'%(LANGUAGE(32098),'Seeking','%s...'%(str(dir)),'%s...'%(str(item.get('file',''))))
dia = DIALOG.progressDialog(int((idx*100)//len(items)), control=dia, message=msg)
valid = __seek(item, citem, cnt, dia)
if valid is None: cnt -=1
else: break
DIALOG.progressDialog(100,control=dia)
return valid
with self.toggleSpinner(allow=spinner):
if __vfs(path, citem): return __set(path, citem)
DIALOG.notificationDialog(LANGUAGE(32030))
return None, citem
def openEditor(self, path):
self.log('openEditor, path = %s'%(path))
if '|' in path:
path = path.split('|')
path = path[0]#prompt user to select:
media = 'video' if 'video' in path else 'music'
if '.xsp' in path: return self.openEditor(path,media)
elif '.xml' in path: return self.openNode(path,media)
def previewChannel(self, citem, retCntrl=None):
def __buildItem(fileList, fitem):
return self.buildListItem('%s| %s'%(fileList.index(fitem),fitem.get('showlabel',fitem.get('label'))), fitem.get('file') ,icon=(getThumb(fitem,opt=EPG_ARTWORK) or {0:FANART,1:COLOR_LOGO}[EPG_ARTWORK]))
def __fileList(citem):
from builder import Builder
builder = Builder()
fileList = []
start_time = 0
end_time = 0
if PROPERTIES.isInterruptActivity(): PROPERTIES.setInterruptActivity(False)
while not self.monitor.abortRequested() and PROPERTIES.isRunning('OVERLAY_MANAGER'):
if self.monitor.waitForAbort(1.0): break
elif not PROPERTIES.isRunning('builder.build') and not PROPERTIES.isInterruptActivity():
DIALOG.notificationDialog('%s: [B]%s[/B]\n%s'%(LANGUAGE(32236),citem.get('name','Untitled'),LANGUAGE(32140)))
tmpcitem = citem.copy()
tmpcitem['id'] = getChannelID(citem['name'], citem['path'], random.random())
start_time = time.time()
fileList = builder.build([tmpcitem],preview=True)
end_time = time.time()
if not fileList or isinstance(fileList,list): break
del builder
if not PROPERTIES.isInterruptActivity(): PROPERTIES.setInterruptActivity(True)
return fileList, round(abs(end_time-start_time),2)
if not PROPERTIES.isRunning('previewChannel'):
with PROPERTIES.chkRunning('previewChannel'), self.toggleSpinner():
listitems = []
fileList, run_time = __fileList(citem)
if not isinstance(fileList,list) and not fileList: DIALOG.notificationDialog('%s or\n%s'%(LANGUAGE(32030),LANGUAGE(32000)))
else:
listitems = poolit(__buildItem)(*(fileList,fileList))
self.log('previewChannel, id = %s, listitems = %s'%(citem['id'],len(listitems)))
if len(listitems) > 0: return DIALOG.selectDialog(listitems, header='%s: [B]%s[/B] - Build Time: [B]%ss[/B]'%(LANGUAGE(32235),citem.get('name','Untitled'),f"{run_time:.2f}"))
if retCntrl: self.setFocusId(retCntrl)
def getMontiorList(self):
self.log('getMontiorList')
try:
with self.toggleSpinner():
labels = sorted(set([cleanLabel(value).title() for info in DIALOG.getInfoMonitor() for key, value in list(info.items()) if value not in ['','..'] and key not in ['path','logo']]))
itemLST = [self.buildListItem(label,icon=ICON) for label in labels]
if len(itemLST) == 0: raise Exception()
itemSEL = DIALOG.selectDialog(itemLST,LANGUAGE(32078)%('Name'),useDetails=True,multi=False)
if itemSEL is not None: return itemLST[itemSEL]
else: raise Exception()
except: return xbmcgui.ListItem(LANGUAGE(32079))
def clearChannel(self, item, prompt=True, open=False):
self.log('clearChannel, channelPOS = %s'%(item['number'] - 1))
with self.toggleSpinner():
if item['number'] > CHANNEL_LIMIT: return DIALOG.notificationDialog(LANGUAGE(32238))
elif prompt and not DIALOG.yesnoDialog(LANGUAGE(32073)): return item
self.madeItemchange = True
nitem = self.newChannel.copy()
nitem['number'] = item['number'] #preserve channel number
self.saveChannelItems(nitem, open)
def moveChannel(self, citem, channelPOS):
self.log('moveChannel, channelPOS = %s'%(channelPOS))
if citem['number'] > CHANNEL_LIMIT: return DIALOG.notificationDialog(LANGUAGE(32064))
retval = DIALOG.inputDialog(LANGUAGE(32137), key=xbmcgui.INPUT_NUMERIC, opt=citem['number'])
if retval:
retval = int(retval)
if (retval > 0 and retval < CHANNEL_LIMIT) and retval != channelPOS + 1:
if DIALOG.yesnoDialog('%s %s %s from [B]%s[/B] to [B]%s[/B]?'%(LANGUAGE(32136),citem['name'],LANGUAGE(32023),citem['number'],retval)):
with self.toggleSpinner():
if retval in [channel.get('number') for channel in self.newChannels if channel.get('path')]: DIALOG.notificationDialog(LANGUAGE(32138))
else:
self.madeItemchange = True
nitem = self.newChannel.copy()
nitem['number'] = channelPOS + 1
self.newChannels[channelPOS] = nitem
citem['number'] = retval
self.saveChannelItems(citem)
def switchLogo(self, channelData, channelPOS):
def __cleanLogo(chlogo):
#todo convert resource from vfs to fs
# return chlogo.replace('resource://','special://home/addons/')
# resource = path.replace('/resources','').replace(,)
# resource://resource.images.studios.white/Amazon.png
return chlogo
def __select():
def _build(logos, logo):
label = os.path.splitext(os.path.basename(logo))[0]
return self.buildListItem('%s| %s'%(logos.index(logo)+1, label.upper() if len(label) <= 4 else label.title()), unquoteString(logo), logo, [logo])
DIALOG.notificationDialog(LANGUAGE(32140))
with self.toggleSpinner():
chname = channelData.get('name')
logos = self.resource.selectLogo(channelData)
listitems = poolit(_build)(*(logos,logos))
select = DIALOG.selectDialog(listitems,'%s (%s)'%(LANGUAGE(32066).split('[CR]')[1],chname),useDetails=True,multi=False)
if select is not None: return listitems[select].getPath()
def __browse():
with self.toggleSpinner():
chname = channelData.get('name')
retval = DIALOG.browseSources(type=1,heading='%s (%s)'%(LANGUAGE(32066).split('[CR]')[0],chname), default=channelData.get('icon',''), shares='files', mask=xbmc.getSupportedMedia('picture'), exclude=[12,13,14,15,16,17,21,22])
if FileAccess.copy(__cleanLogo(retval), os.path.join(LOGO_LOC,'%s%s'%(chname,retval[-4:])).replace('\\','/')):
if FileAccess.exists(os.path.join(LOGO_LOC,'%s%s'%(chname,retval[-4:])).replace('\\','/')):
return os.path.join(LOGO_LOC,'%s%s'%(chname,retval[-4:])).replace('\\','/')
return retval
def __match():
with self.toggleSpinner():
return self.resource.getLogo(channelData,auto=True)
if not channelData.get('name'): return DIALOG.notificationDialog(LANGUAGE(32065))
chlogo = None
retval = DIALOG.yesnoDialog(LANGUAGE(32066), heading ='%s - %s'%(ADDON_NAME,LANGUAGE(32172)),
nolabel = LANGUAGE(32067), #Select
yeslabel = LANGUAGE(32068), #Browse
customlabel = LANGUAGE(30022)) #Auto
if retval == 0: chlogo = __select()
elif retval == 1: chlogo = __browse()
elif retval == 2: chlogo = __match()
else: DIALOG.notificationDialog(LANGUAGE(32070))
if chlogo and chlogo != LOGO:
self.log('switchLogo, chname = %s, chlogo = %s'%(channelData.get('name'),chlogo))
DIALOG.notificationDialog(LANGUAGE(32139))
self.madeChanges = True
channelData['logo'] = chlogo
self.newChannels[channelPOS] = channelData
self.fillChanList(self.newChannels,refresh=True,focus=channelPOS)
def isVisible(self, cntrl):
try:
if isinstance(cntrl, int): cntrl = self.getControl(cntrl)
state = cntrl.isVisible()
except: state = self.cntrlStates.get(cntrl.getId(),False)
self.log('isVisible, cntrl = %s, state = %s'%(cntrl.getId(),state))
return state
def setVisibility(self, cntrl, state):
try:
if isinstance(cntrl, int): cntrl = self.getControl(cntrl)
cntrl.setVisible(state)
self.cntrlStates[cntrl.getId()] = state
self.log('setVisibility, cntrl = ' + str(cntrl.getId()) + ', state = ' + str(state))
except Exception as e: self.log("setVisibility, failed! %s"%(e), xbmc.LOGERROR)
def getLabels(self, cntrl):
try:
if isinstance(cntrl, int): cntrl = self.getControl(cntrl)
return cntrl.getLabel(), cntrl.getLabel2()
except Exception as e: return '',''
def setImages(self, cntrl, image='NA.png'):
try:
if isinstance(cntrl, int): cntrl = self.getControl(cntrl)
cntrl.setImage(image)
except Exception as e: self.log("setImages, failed! %s"%(e), xbmc.LOGERROR)
def setLabels(self, cntrl, label='', label2=''):
try:
if isinstance(cntrl, int): cntrl = self.getControl(cntrl)
cntrl.setLabel(str(label), str(label2))
self.setVisibility(cntrl,(len(label) > 0 or len(label2) > 0))
except Exception as e: self.log("setLabels, failed! %s"%(e), xbmc.LOGERROR)
def setEnableCondition(self, cntrl, condition):
try:
if isinstance(cntrl, int): cntrl = self.getControl(cntrl)
cntrl.setEnableCondition(condition)
except Exception as e: self.log("setEnableCondition, failed! %s"%(e), xbmc.LOGERROR)
def resetPagination(self, citem):
if isinstance(citem, list): [self.resetPagination(item) for item in citem]
else:
with self.toggleSpinner():
self.log('resetPagination, citem = %s'%(citem))
[self.jsonRPC.resetPagination(citem.get('id'), path) for path in citem.get('path',[]) if citem.get('id')]
def saveChannelItems(self, citem: dict={}, open=False):
self.log('saveChannelItems [%s], open = %s'%(citem.get('id'),open))
if self.madeItemchange:
self.madeChanges = True
self.newChannels[citem['number'] - 1] = citem
self.fillChanList(self.newChannels,True,(citem['number'] - 1),citem if open else None)
self.madeItemchange = False
return citem
def closeChannel(self, citem, focus=0, open=False):
self.log('closeChannel')
if self.madeItemchange:
if DIALOG.yesnoDialog(LANGUAGE(32243)): return self.saveChannelItems(citem, open)
self.togglechanList(focus=focus)
def saveChanges(self):
self.log("saveChanges")
def __validateChannels(channelList):
def _validate(citem):
if citem.get('name') and citem.get('path'):
if citem['number'] <= CHANNEL_LIMIT: citem['type'] = "Custom"
return self.setID(citem)
channelList = setDictLST(self.channels.sortChannels([_f for _f in [_validate(channel) for channel in channelList] if _f]))
self.log('__validateChannels, channelList = %s'%(len(channelList)))
return channelList
if self.madeChanges:
if DIALOG.yesnoDialog(LANGUAGE(32076)):
with self.toggleSpinner():
channels = __validateChannels(self.newChannels)
changes = diffLSTDICT(__validateChannels(self.channelList),channels)
added = [citem.get('id') for citem in changes.get('added',[])]
removed = [citem.get('id') for citem in changes.get('removed',[])]
ids = added+removed
citems = changes.get('added',[])+changes.get('removed',[])
self.log("saveChanges, channels = %s, added = %s, removed = %s"%(len(channels), len(added), len(removed)))
if self.server:
payload = {'uuid':SETTINGS.getMYUUID(),'name':self.friendly,'payload':channels}
requestURL('http://%s/%s'%(self.server.get('host'),CHANNELFLE), data=payload, header=HEADER, json_data=True)
else:
self.channels.setChannels(channels) #save changes
self.resetPagination(citems) #clear auto pagination cache
SETTINGS.setResetChannels(ids) #clear guidedata
PROPERTIES.setUpdateChannels(ids) #update channel meta.
self.madeChanges = False
self.closeManager()
def closeManager(self):
self.log('closeManager')
if self.madeChanges: self.saveChanges()
else: self.close()
def __exit__(self):
self.log('__exit__')
del self.resource
del self.jsonRPC
del self.rule
del self.channels
def getFocusItems(self, controlId=None):
focusItems = dict()
if controlId in [5,6,7,9001,9002,9003,9004]:
label, label2 = self.getLabels(controlId)
try: snum = int(cleanLabel(label.replace("|",'')))
except: snum = 1
if self.isVisible(self.chanList):
cntrl = controlId
sitem = self.chanList.getSelectedItem()
citem = loadJSON(sitem.getProperty('citem'))
chnum = (citem.get('number') or snum)
chpos = self.chanList.getSelectedPosition()
itpos = -1
elif self.isVisible(self.itemList):
cntrl = controlId
sitem = self.itemList.getSelectedItem()
citem = loadJSON(sitem.getProperty('citem'))
chnum = (citem.get('number') or snum)
chpos = chnum - 1
itpos = self.itemList.getSelectedPosition()
else:
sitem = xbmcgui.ListItem()
cntrl = (self.focusItems.get('cntrl') or controlId)
citem = (self.focusItems.get('citem') or {})
chnum = (self.focusItems.get('number') or snum)
chpos = (self.focusItems.get('chpos') or chnum - 1)
itpos = (self.focusItems.get('itpos') or -1)
self.focusItems.update({'retCntrl':cntrl,'label':label,'label2':label2,'number':chnum,'chpos':chpos,'itpos':itpos,'item':sitem,'citem':citem})
self.log('getFocusItems, controlId = %s, focusItems = %s'%(controlId,self.focusItems))
return self.focusItems
def onAction(self, act):
actionId = act.getId()
if (time.time() - self.lastActionTime) < .5 and actionId not in ACTION_PREVIOUS_MENU: action = ACTION_INVALID # during certain times we just want to discard all input
else:
if actionId in ACTION_PREVIOUS_MENU:
self.log('onAction: actionId = %s'%(actionId))
if xbmcgui.getCurrentWindowDialogId() == "13001": BUILTIN.executebuiltin("Action(Back)")
elif self.isVisible(self.itemList): self.closeChannel(self.getFocusItems().get('citem'),self.getFocusItems().get('position'))
elif self.isVisible(self.chanList): self.closeManager()
def onFocus(self, controlId):
self.log('onFocus: controlId = %s'%(controlId))
def onClick(self, controlId):
focusItems = self.getFocusItems(controlId)
focusID = focusItems.get('retCntrl')
focusLabel = focusItems.get('label')
focusNumber = focusItems.get('number',0)
focusCitem = focusItems.get('citem')
focusPOS = focusItems.get('chpos',0)
autoTuned = focusNumber > CHANNEL_LIMIT
self.log('onClick: controlId = %s\nitems = %s'%(controlId,focusItems))
if controlId == 0: self.closeManager()
elif controlId == 5: self.buildChannelItem(focusCitem) #item list
elif controlId == 6:
if self.lockAutotune and autoTuned: DIALOG.notificationDialog(LANGUAGE(32064))
else: self.buildChannelItem(self.itemInput(focusItems.get('item')),focusItems.get('item').getProperty('key'))
elif controlId == 10: #logo button
if self.lockAutotune and autoTuned: DIALOG.notificationDialog(LANGUAGE(32064))
else: self.switchLogo(focusCitem,focusPOS)
elif controlId in [9001,9002,9003,9004]: #side buttons
if focusLabel == LANGUAGE(32059): self.saveChanges() #Save
elif focusLabel == LANGUAGE(32061): self.clearChannel(focusCitem) #Delete
elif focusLabel == LANGUAGE(32239): self.clearChannel(focusCitem,open=True)#Clear
elif focusLabel == LANGUAGE(32136): self.moveChannel(focusCitem,focusPOS) #Move
elif focusLabel == LANGUAGE(32062): #Close
if self.isVisible(self.itemList): self.closeChannel(focusCitem,focus=focusPOS)
elif self.isVisible(self.chanList): self.closeManager()
elif focusLabel == LANGUAGE(32060): #Cancel
if self.isVisible(self.itemList): self.closeChannel(focusCitem)
elif self.isVisible(self.chanList): self.closeManager()
elif focusLabel == LANGUAGE(32240): #Confirm
if self.isVisible(self.itemList): self.saveChannelItems(focusCitem)
elif self.isVisible(self.chanList): self.saveChanges()
elif focusLabel == LANGUAGE(32235): #Preview
if self.isVisible(self.itemList) and self.madeItemchange: self.closeChannel(focusCitem, open=True)
self.previewChannel(focusCitem, focusID)

View File

@@ -0,0 +1,211 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
#
# -*- coding: utf-8 -*-
from globals import *
from server import Discovery
class Service:
from jsonrpc import JSONRPC
player = PLAYER()
monitor = MONITOR()
jsonRPC = JSONRPC()
def _interrupt(self) -> bool:
return PROPERTIES.isPendingInterrupt()
def _suspend(self) -> bool:
return PROPERTIES.isPendingSuspend()
class Multiroom:
def __init__(self, sysARG=sys.argv, service=None):
self.log('__init__, sysARG = %s'%(sysARG))
if service is None: service = Service()
self.service = service
self.jsonRPC = service.jsonRPC
self.cache = service.jsonRPC.cache
self.sysARG = sysARG
self.uuid = SETTINGS.getMYUUID()
self.friendly = SETTINGS.getFriendlyName()
self.remoteHost = PROPERTIES.getRemoteHost()
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
@cacheit(checksum=PROPERTIES.getInstanceID(), expiration=datetime.timedelta(minutes=FIFTEEN))
def _getStatus(self):
return self.jsonRPC.getSettingValue("services.zeroconf",default=False)
def _chkDiscovery(self):
self.log('_chkDiscovery')
Discovery(service=self.service, multiroom=self)
def chkServers(self, servers={}):
def __chkSettings(settings):
[hasAddon(id,install=True,enable=True) for k,addons in list(settings.items()) for id in addons if id.startswith(('resource','plugin'))]
if isinstance(servers,bool): servers = {} #temp fix remove after a by next build
if not servers: servers = self.getDiscovery()
PROPERTIES.setServers(len(servers) > 0)
for server in list(servers.values()):
online = server.get('online',False)
response = self.getRemote(server.get('remotes',{}).get('bonjour'))
if response: server['online'] = True
else: server['online'] = False
if server.get('enabled',False):
if online != server.get('online',False): DIALOG.notificationDialog('%s: %s'%(server.get('name'),LANGUAGE(32211)%({True:'green',False:'red'}[server.get('online',False)],{True:LANGUAGE(32158),False:LANGUAGE(32253)}[server.get('online',False)])))
__chkSettings(loadJSON(server.get('settings')))
SETTINGS.setSetting('Select_server','|'.join([LANGUAGE(32211)%({True:'green',False:'red'}[server.get('online',False)],server.get('name')) for server in self.getEnabled(servers)]))
self.log('chkServers, servers = %s'%(len(servers)))
self.setDiscovery(servers)
return servers
def getDiscovery(self):
servers = getJSON(SERVER_LOC).get('servers',{})
if isinstance(servers,bool): servers = {} #temp fix remove after a by next build
return servers
def setDiscovery(self, servers={}):
return setJSON(SERVER_LOC,{"servers":servers})
def getEnabled(self, servers={}):
if not servers: servers = self.getDiscovery()
enabled = [server for server in list(servers.values()) if server.get('enabled',False)]
PROPERTIES.setEnabledServers(len(enabled) > 0)
return enabled
@cacheit(expiration=datetime.timedelta(minutes=FIFTEEN), json_data=True)
def getRemote(self, remote):
self.log("getRemote, remote = %s"%(remote))
cacheName = 'getRemote.%s'%(remote)
return requestURL(remote, header={'Accept':'application/json'}, json_data=True, cache=self.cache, checksum=self.uuid, life=datetime.timedelta(days=MAX_GUIDEDAYS))
def addServer(self, payload={}):
self.log('addServer, name = %s'%(payload.get('name')))
if payload and payload.get('name') and payload.get('host'):
payload['online'] = True
servers = self.getDiscovery()
server = servers.get(payload.get('name'),{})
if not server:
payload['enabled'] = not bool(SETTINGS.getSettingBool('Debug_Enable')) #set enabled by default when not debugging.
self.log('addServer, adding server = %s'%(payload))
DIALOG.notificationDialog('%s: %s'%(LANGUAGE(32047),payload.get('name')))
servers[payload['name']] = payload
else:
payload['enabled'] = server.get('enabled',False)
if payload.get('md5',server.get('md5')) != server.get('md5'):
self.log('addServer, updating server = %s'%(server))
servers.update({payload['name']:payload})
if self.setDiscovery(self.chkServers(servers)):
instancePath = SETTINGS.hasPVRInstance(server.get('name'))
if payload.get('enabled',False) and not instancePath: changed = SETTINGS.setPVRRemote(payload.get('host'),payload.get('name'))
elif not payload.get('enabled',False) and instancePath: changed = FileAccess.delete(instancePath)
else: changed = False
if changed: PROPERTIES.setPropTimer('chkPVRRefresh')
self.log('addServer, payload changed, chkPVRRefresh = %s'%(changed))
return True
def delServer(self, servers={}):
self.log('delServer')
def __build(idx, payload):
return LISTITEMS.buildMenuListItem(payload.get('name'),'%s - %s: Channels (%s)'%(LANGUAGE(32211)%({True:'green',False:'red'}[payload.get('online',False)],{True:LANGUAGE(32158),False:LANGUAGE(32253)}[payload.get('online',False)]),payload.get('host'),len(payload.get('channels',[]))),icon=DUMMY_ICON.format(text=str(idx+1)),url=dumpJSON(payload))
with BUILTIN.busy_dialog():
if not servers: servers = self.getDiscovery()
lizlst = [__build(idx, server) for idx, server in enumerate(list(servers.values()))]
selects = DIALOG.selectDialog(lizlst,LANGUAGE(32183))
if not selects is None:
[servers.pop(liz.getLabel()) for idx, liz in enumerate(lizlst) if idx in selects]
if self.setDiscovery(self.chkServers(servers)):
return DIALOG.notificationDialog(LANGUAGE(30046))
def selServer(self):
self.log('selServer')
def __build(idx, payload):
return LISTITEMS.buildMenuListItem(payload.get('name'),'%s - %s: Channels (%s)'%(LANGUAGE(32211)%({True:'green',False:'red'}[payload.get('online',False)],{True:LANGUAGE(32158),False:LANGUAGE(32253)}[payload.get('online',False)]),payload.get('host'),len(payload.get('channels',[]))),icon=DUMMY_ICON.format(text=str(idx+1)),url=dumpJSON(payload))
with BUILTIN.busy_dialog():
servers = self.getDiscovery()
lizlst = [__build(idx, server) for idx, server in enumerate(list(servers.values()))]
if len(lizlst) > 0: lizlst.insert(0,LISTITEMS.buildMenuListItem('[COLOR=white][B]- %s[/B][/COLOR]'%(LANGUAGE(30046)),LANGUAGE(33046)))
else: return
selects = DIALOG.selectDialog(lizlst,LANGUAGE(30130),preselect=[idx for idx, listitem in enumerate(lizlst) if loadJSON(listitem.getPath()).get('enabled',False)])
if not selects is None:
if 0 in selects: return self.delServer(servers)
else:
for idx, liz in enumerate(lizlst):
if idx == 0: continue
instancePath = SETTINGS.hasPVRInstance(liz.getLabel())
if idx in selects:
if not servers[liz.getLabel()].get('enabled',False):
DIALOG.notificationDialog(LANGUAGE(30099)%(liz.getLabel()))
servers[liz.getLabel()]['enabled'] = True
if not instancePath:
if SETTINGS.setPVRRemote(servers[liz.getLabel()].get('host'),liz.getLabel()): PROPERTIES.setPropTimer('chkPVRRefresh')
else:
if servers[liz.getLabel()].get('enabled',False):
DIALOG.notificationDialog(LANGUAGE(30100)%(liz.getLabel()))
servers[liz.getLabel()]['enabled'] = False
if instancePath: FileAccess.delete(instancePath)
with BUILTIN.busy_dialog():
return self.setDiscovery(self.chkServers(servers))
def enableZeroConf(self):
self.log('enableZeroConf')
if SETTINGS.getSetting('ZeroConf_Status') == '[COLOR=red][B]%s[/B][/COLOR]'%(LANGUAGE(32253)):
if BUILTIN.getInfoLabel('Platform.Windows','System'):
BUILTIN.executescript('special://home/addons/%s/resources/lib/utilities.py, Show_ZeroConf_QR'%(ADDON_ID))
if DIALOG.yesnoDialog(message=LANGUAGE(30129)):
with PROPERTIES.interruptActivity():
if self.jsonRPC.setSettingValue("services.zeroconf",True,queue=False):
DIALOG.notificationDialog(LANGUAGE(32219)%(LANGUAGE(30035)))
PROPERTIES.setEpochTimer('chkKodiSettings')
else: DIALOG.notificationDialog(LANGUAGE(32219)%(LANGUAGE(30034)))
def run(self):
try: param = self.sysARG[1]
except: param = None
if param == 'Enable_ZeroConf':
ctl = (5,1)
self.enableZeroConf()
elif param == 'Select_Server':
ctl = (5,11)
self.selServer()
elif param == 'Remove_server':
ctl = (5,12)
return SETTINGS.openSettings(ctl)
if __name__ == '__main__': timerit(Multiroom(sys.argv).run)(0.1)

View File

@@ -0,0 +1,433 @@
# Copyright (C) 2025 Lunatixz
# This file is part of PseudoTV Live.
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# https://github.com/xbmc/xbmc/blob/master/xbmc/input/actions/ActionIDs.h
# https://github.com/xbmc/xbmc/blob/master/xbmc/input/Key.h
# -*- coding: utf-8 -*-
from globals import *
from resources import Resources
WH, WIN = BUILTIN.getResolution()
class Busy(xbmcgui.WindowXMLDialog):
def __init__(self, *args, **kwargs):
xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs)
def onAction(self, act):
actionId = act.getId()
log('Busy: onAction: actionId = %s'%(actionId))
if actionId in ACTION_PREVIOUS_MENU: self.close()
class Background(xbmcgui.WindowXMLDialog):
def __init__(self, *args, **kwargs):
xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs)
self.player = kwargs.get('player', None)
self.sysInfo = kwargs.get('sysInfo', self.player.sysInfo)
self.citem = self.sysInfo.get('citem',{})
self.fitem = self.sysInfo.get('fitem',{})
self.nitem = self.player.jsonRPC.getNextItem(self.citem,self.sysInfo.get('nitem'))
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def onInit(self):
try:
self.log('onInit, citem = %s\nfitem = %ss\nnitem = %s'%(self.citem,self.fitem,self.nitem))
logo = (self.citem.get('logo') or BUILTIN.getInfoLabel('Art(icon)','Player') or LOGO)
land = (getThumb(self.nitem) or COLOR_FANART)
chname = (self.citem.get('name') or BUILTIN.getInfoLabel('ChannelName','VideoPlayer'))
nowTitle = (self.fitem.get('label') or BUILTIN.getInfoLabel('Title','VideoPlayer'))
nextTitle = (self.nitem.get('showlabel') or BUILTIN.getInfoLabel('NextTitle','VideoPlayer') or chname)
try: nextTime = epochTime(self.nitem['start']).strftime('%I:%M%p')
except Exception as e:
self.log("__init__, nextTime failed! %s\nstart = %s"%(e,self.nitem.get('start')), xbmc.LOGERROR)
nextTime = BUILTIN.getInfoLabel('NextStartTime','VideoPlayer')
onNow = '%s on %s'%(nowTitle,chname) if chname not in validString(nowTitle) else nowTitle
onNext = '@ %s: %s'%(nextTime,nextTitle)
window_w, window_h = WH # window_h, window_w = (self.getHeight(), self.getWidth())
onNextX, onNextY = abs(int(window_w // 9)), abs(int(window_h // 16) - window_h) - 356 #auto
self.getControl(40001).setPosition(onNextX, onNextY)
self.getControl(40001).setVisibleCondition('[Player.Playing + !Window.IsVisible(fullscreeninfo) + Window.IsVisible(fullscreenvideo)]')
self.getControl(40001).setAnimations([('WindowOpen' , 'effect=zoom start=80 end=100 center=%s,%s delay=160 tween=back time=240 reversible=false'%(onNextX, onNextY)),
('WindowOpen' , 'effect=fade start=0 end=100 delay=160 time=240 reversible=false'),
('WindowClose', 'effect=zoom start=100 end=80 center=%s,%s delay=160 tween=back time=240 reversible=false'%(onNextX, onNextY)),
('WindowClose', 'effect=fade start=100 end=0 time=240 reversible=false'),
('Visible' , 'effect=zoom start=80 end=100 center=%s,%s delay=160 tween=back time=240 reversible=false'%(onNextX, onNextY)),
('Visible' , 'effect=fade end=100 time=240 reversible=false')])
self.getControl(40002).setImage(COLOR_LOGO if logo.endswith('wlogo.png') else logo)
self.getControl(40003).setText('%s %s[CR]%s [B]%s[/B]'%(LANGUAGE(32104),onNow,LANGUAGE(32116),onNext))
self.getControl(40004).setImage(land)
except Exception as e:
self.log("onInit, failed! %s"%(e), xbmc.LOGERROR)
self.close()
class Restart(xbmcgui.WindowXMLDialog):
closing = False
def __init__(self, *args, **kwargs):
xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs)
self.player = kwargs.get('player', None)
self.sysInfo = kwargs.get('sysInfo', self.player.sysInfo)
self.monitor = self.player.service.monitor
if bool(self.player.restartPercentage) and self.sysInfo.get('fitem'):
progress = self.player.getPlayerProgress()
self.log("__init__, restartPercentage = %s, progress = %s"%(self.player.restartPercentage, progress))
if (progress >= self.player.restartPercentage and progress < self.player.maxProgress) and not isFiller(self.sysInfo.get('fitem',{})):
self.doModal()
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def _isVisible(self, control):
try: return control.isVisible()
except: return (BUILTIN.getInfoBool('Playing','Player') and not bool(BUILTIN.getInfoBool('IsVisible(fullscreeninfo)','Window')) | BUILTIN.getInfoBool('IsVisible(fullscreenvideo)','Window'))
def onInit(self):
self.log("onInit")
try:
prog = 0
wait = OSD_TIMER*2
tot = wait
control = self.getControl(40000)
control.setVisibleCondition('[Player.Playing + !Window.IsVisible(fullscreeninfo) + Window.IsVisible(fullscreenvideo)]')
xpos = control.getX()
while not self.monitor.abortRequested():
if (self.monitor.waitForAbort(0.5) or self._isVisible(control) or self.closing): break
while not self.monitor.abortRequested():
if (self.monitor.waitForAbort(0.5) or wait < 0 or self.closing or not self.player.isPlaying()): break
else:
prog = int((abs(wait-tot)*100)//tot)
if prog > 0: control.setAnimations([('Conditional', 'effect=zoom start=%s,100 end=%s,100 time=1000 center=%s,100 condition=True'%((prog-20),(prog),xpos))])
wait -= 1
control.setAnimations([('Conditional', 'effect=fade start=%s end=0 time=240 delay=0.240 condition=True'%(prog))])
control.setVisible(False)
self.setFocusId(40001)
except Exception as e: self.log("onInit, failed! %s\ncitem = %s"%(e,self.sysInfo), xbmc.LOGERROR)
self.log("onInit, closing")
self.close()
def onAction(self, act):
actionId = act.getId()
self.log('onAction: actionId = %s'%(actionId))
self.closing = True
if actionId in ACTION_SELECT_ITEM and self.getFocusId(40001):
if self.sysInfo.get('isPlaylist',False): self.player.seekTime(0)
elif self.sysInfo.get('fitem'):
with BUILTIN.busy_dialog():
liz = LISTITEMS.buildItemListItem(self.sysInfo.get('fitem',{}))
liz.setProperty('sysInfo',encodeString(dumpJSON(self.sysInfo)))
self.player.stop()
timerit(self.player.play)(1.0,[self.sysInfo.get('fitem',{}).get('catchup-id'),liz])
else: DIALOG.notificationDialog(LANGUAGE(30154))
elif actionId == ACTION_MOVE_UP: timerit(BUILTIN.executebuiltin)(0.1,['AlarmClock(up,Action(up),.5,true,false)'])
elif actionId == ACTION_MOVE_DOWN: timerit(BUILTIN.executebuiltin)(0.1,['AlarmClock(down,Action(down),.5,true,false)'])
elif actionId in ACTION_PREVIOUS_MENU: timerit(BUILTIN.executebuiltin)(0.1,['AlarmClock(back,Action(back),.5,true,false)'])
def onClose(self):
self.log("onClose")
self.closing = True
class Overlay():
channelBug = None
vignette = None
controlManager = dict()
def __init__(self, *args, **kwargs):
self.log("__init__")
self.player = kwargs.get('player', None)
self.sysInfo = kwargs.get('sysInfo', self.player.sysInfo)
self.service = self.player.service
self.jsonRPC = self.player.jsonRPC
self.runActions = self.player.runActions
self.resources = Resources(service=self.service)
self.window = xbmcgui.Window(12005)
self.window_w, self.window_h = WH
#vignette rules
self.enableVignette = False
self.defaultView = self.jsonRPC.getViewMode()
self.vinView = self.defaultView
self.vinImage = ''
#channelBug rules
self.enableChannelBug = SETTINGS.getSettingBool('Enable_ChannelBug')
self.forceBugDiffuse = SETTINGS.getSettingBool('Force_Diffuse')
self.channelBugColor = '0x%s'%((SETTINGS.getSetting('ChannelBug_Color') or 'FFFFFFFF'))
self.channelBugFade = SETTINGS.getSettingInt('ChannelBug_Transparency')
try: self.channelBugX, self.channelBugY = eval(SETTINGS.getSetting("Channel_Bug_Position_XY")) #user
except: self.channelBugX, self.channelBugY = abs(int(self.window_w // 9) - self.window_w) - 128, abs(int(self.window_h // 16) - self.window_h) - 128 #auto
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def _hasControl(self, control):
return control in self.controlManager
def _isVisible(self, control):
return self.controlManager.get(control,False)
def _setVisible(self, control, state: bool=False):
self.log('_setVisible, %s = %s'%(control,state))
try:
control.setVisible(state)
return state
except Exception as e:
self.log('_setVisible, failed! control = %s %s'%(control,e))
self._delControl(control)
return False
def _addControl(self, control):
if not self._hasControl(control):
try:
self.log('_addControl, %s'%(control))
self.window.addControl(control)
self.controlManager[control] = self._setVisible(control,True)
except Exception as e:
self.log('_addControl failed! control = %s %s'%(control, e), xbmc.LOGERROR)
self._delControl(control)
def _delControl(self, control):
if self._hasControl(control):
self.log('_delControl, %s'%(control))
try: self.window.removeControl(control)
except Exception as e: self.log('_delControl failed! control = %s %s'%(control, e), xbmc.LOGERROR)
self.controlManager.pop(control)
def open(self):
if self.sysInfo.get('citem',{}):
self.runActions(RULES_ACTION_OVERLAY_OPEN, self.sysInfo.get('citem',{}), inherited=self)
self.log("open, enableVignette = %s, enableChannelBug = %s"%(self.enableVignette, self.enableChannelBug))
if self.enableVignette:
window_h, window_w = (self.window.getHeight(), self.window.getWidth())
self.vignette = xbmcgui.ControlImage(0, 0, window_w, window_h, ' ', aspectRatio=0)
self._addControl(self.vignette)
self.vignette.setImage(self.vinImage)
if self.vinView != self.defaultView: timerit(self.jsonRPC.setViewMode)(0.5,[self.vinView])
self.vignette.setAnimations([('Conditional', 'effect=fade start=0 end=100 time=240 delay=160 condition=True reversible=True')])
self.log('enableVignette, vinImage = %s, vinView = %s'%(self.vinImage,self.vinView))
if self.enableChannelBug:
self.channelBug = xbmcgui.ControlImage(self.channelBugX, self.channelBugY, 128, 128, ' ', aspectRatio=2)
self._addControl(self.channelBug)
logo = self.sysInfo.get('citem',{}).get('logo',(BUILTIN.getInfoLabel('Art(icon)','Player') or LOGO))
if self.forceBugDiffuse: self.channelBug.setColorDiffuse(self.channelBugColor)
elif self.resources.isMono(logo): self.channelBug.setColorDiffuse(self.channelBugColor)
self.channelBug.setImage(logo)
self.channelBug.setAnimations([('Conditional', 'effect=fade start=0 end=100 time=2000 delay=1000 condition=Control.IsVisible(%i) reversible=false'%(self.channelBug.getId())),
('Conditional', 'effect=fade start=100 end=%i time=1000 delay=3000 condition=Control.IsVisible(%i) reversible=false'%(self.channelBugFade,self.channelBug.getId()))])
self.log('enableChannelBug, logo = %s, channelBugColor = %s, window = (%s,%s)'%(logo,self.channelBugColor,self.window_h, self.window_w))
else: self.close()
def close(self):
self.log("close")
self._delControl(self.vignette)
self._delControl(self.channelBug)
if self.vinView != self.defaultView: timerit(self.jsonRPC.setViewMode)(0.5,[self.defaultView])
self.runActions(RULES_ACTION_OVERLAY_CLOSE, self.sysInfo.get('citem',{}), inherited=self)
class OnNext(xbmcgui.WindowXMLDialog):
closing = False
totalTime = 0
threshold = 0
remaining = 0
intTime = 0
def __init__(self, *args, **kwargs):
xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs)
self.player = kwargs.get('player' , None)
self.sysInfo = kwargs.get('sysInfo' , self.player.sysInfo)
self.onNextMode = kwargs.get('mode' , SETTINGS.getSettingInt('OnNext_Mode'))
self.onNextPosition = kwargs.get('position' , SETTINGS.getSetting("OnNext_Position_XY"))
self.jsonRPC = self.player.jsonRPC
self.monitor = self.player.service.monitor
self.citem = self.sysInfo.get('citem',{})
self.fitem = self.sysInfo.get('fitem',{})
self.nitem = self.jsonRPC.getNextItem(self.citem,self.sysInfo.get('nitem'))
self.window = xbmcgui.Window(12005)
self.window_w, self.window_h = WH #self.window_h, self.window_w = (self.window.getHeight(), self.window.getWidth())
try: self.onNextX, self.onNextY = eval(self.onNextPosition) #user
except: self.onNextX, self.onNextY = abs(int(self.window_w // 9)), abs(int(self.window_h // 16) - self.window_h) - 356 #auto
self.log('__init__, enableOnNext = %s, onNextMode = %s, onNextX = %s, onNextY = %s'%(bool(self.onNextMode),self.onNextMode,self.onNextX,self.onNextY))
if not isFiller(self.fitem):
self.totalTime = int(self.player.getPlayerTime() * (self.player.maxProgress / 100))
self.threshold = abs((self.totalTime - (self.totalTime * .75)) - (ONNEXT_TIMER*3))
self.remaining = floor(self.totalTime - self.player.getPlayedTime())
self.intTime = roundupDIV(self.threshold,3)
self.log('__init__, totalTime = %s, threshold = %s, remaining = %s, intTime = %s'%(self.totalTime,self.threshold,self.remaining,self.intTime))
if self.remaining >= self.intTime:
self.doModal()
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def onInit(self):
def __chkCitem():
return sorted(self.citem) == sorted(self.player.sysInfo.get('citem',{}))
try:
self.log('onInit, citem = %s\nfitem = %ss\nnitem = %s'%(self.citem,self.fitem,self.nitem))
if self.onNextMode in [1,2]:
logo = (self.citem.get('logo') or BUILTIN.getInfoLabel('Art(icon)','Player') or LOGO)
land = (getThumb(self.nitem) or COLOR_FANART)
chname = (self.citem.get('name') or BUILTIN.getInfoLabel('ChannelName','VideoPlayer'))
nowTitle = (self.fitem.get('label') or BUILTIN.getInfoLabel('Title','VideoPlayer'))
nextTitle = (self.nitem.get('showlabel') or BUILTIN.getInfoLabel('NextTitle','VideoPlayer') or chname)
try: nextTime = epochTime(self.nitem['start']).strftime('%I:%M%p')
except Exception as e:
self.log("__init__, nextTime failed! %s\nstart = %s"%(e,self.nitem.get('start')), xbmc.LOGERROR)
nextTime = BUILTIN.getInfoLabel('NextStartTime','VideoPlayer')
onNow = '%s on %s'%(nowTitle,chname) if chname not in validString(nowTitle) else nowTitle
onNext = '@ %s: %s'%(nextTime,nextTitle)
self.getControl(40001).setPosition(self.onNextX, self.onNextY)
self.getControl(40001).setVisibleCondition('[Player.Playing + !Window.IsVisible(fullscreeninfo) + Window.IsVisible(fullscreenvideo)]')
self.getControl(40001).setAnimations([('WindowOpen' , 'effect=zoom start=80 end=100 center=%s,%s delay=160 tween=back time=240 reversible=false'%(self.onNextX, self.onNextY)),
('WindowOpen' , 'effect=fade start=0 end=100 delay=160 time=240 reversible=false'),
('WindowClose', 'effect=zoom start=100 end=80 center=%s,%s delay=160 tween=back time=240 reversible=false'%(self.onNextX, self.onNextY)),
('WindowClose', 'effect=fade start=100 end=0 time=240 reversible=false'),
('Visible' , 'effect=zoom start=80 end=100 center=%s,%s delay=160 tween=back time=240 reversible=false'%(self.onNextX, self.onNextY)),
('Visible' , 'effect=fade end=100 time=240 reversible=false')])
self.onNext_Text = self.getControl(40003)
self.onNext_Text.setVisible(False)
self.onNext_Text.setText('%s %s[CR]%s [B]%s[/B]'%(LANGUAGE(32104),onNow,LANGUAGE(32116),onNext))
if self.onNextMode == 2:
self.onNext_Artwork = self.getControl(40004)
self.onNext_Artwork.setVisible(False)
self.onNext_Artwork.setImage(land)
self.onNext_Text.setVisible(True)
self.onNext_Artwork.setVisible(True)
xbmc.playSFX(BING_WAV)
show = ONNEXT_TIMER*2
while not self.monitor.abortRequested() and not self.closing:
self.log('onInit, showing (%s)'%(show))
if self.monitor.waitForAbort(0.5) or not self.player.isPlaying() or show < 1 or not __chkCitem(): break
else: show -= 1
self.onNext_Text.setVisible(False)
self.onNext_Artwork.setVisible(False)
elif self.onNextMode == 3: self.player.toggleInfo()
elif self.onNextMode == 4: self._updateUpNext(self.fitem,self.nitem)
wait = self.intTime*2
while not self.monitor.abortRequested() and not self.closing:
self.log('onInit, waiting (%s)'%(wait))
if self.monitor.waitForAbort(0.5) or not self.player.isPlaying() or wait < 1 or not __chkCitem(): break
else: wait -= 1
except Exception as e: self.log("onInit, failed! %s"%(e), xbmc.LOGERROR)
self.log("onInit, closing")
self.close()
def _updateUpNext(self, nowItem: dict={}, nextItem: dict={}):
self.log('_updateUpNext')
data = dict()
try:# https://github.com/im85288/service.upnext/wiki/Example-source-code
data.update({"notification_offset":int(floor(self.player.getRemainingTime())) + OSD_TIMER})
current_episode = {"current_episode":{"episodeid" :(nowItem.get("id") or ""),
"tvshowid" :(nowItem.get("tvshowid") or ""),
"title" :(nowItem.get("title") or ""),
"art" :(nowItem.get("art") or ""),
"season" :(nowItem.get("season") or ""),
"episode" :(nowItem.get("episode") or ""),
"showtitle" :(nowItem.get("tvshowtitle") or ""),
"plot" :(nowItem.get("plot") or ""),
"playcount" :(nowItem.get("playcount") or ""),
"rating" :(nowItem.get("rating") or ""),
"firstaired":(nowItem.get("firstaired") or ""),
"runtime" :(nowItem.get("runtime") or "")}}
data.update(current_episode)
except: pass
try:
next_episode = {"next_episode" :{"episodeid" :(nextItem.get("id") or ""),
"tvshowid" :(nextItem.get("tvshowid") or ""),
"title" :(nextItem.get("title") or ""),
"art" :(nextItem.get("art") or ""),
"season" :(nextItem.get("season") or ""),
"episode" :(nextItem.get("episode") or ""),
"showtitle" :(nextItem.get("tvshowtitle") or ""),
"plot" :(nextItem.get("plot" ) or ""),
"playcount" :(nextItem.get("playcount") or ""),
"rating" :(nextItem.get("rating") or ""),
"firstaired":(nextItem.get("firstaired") or ""),
"runtime" :(nextItem.get("runtime") or "")}}
data.update(next_episode)
except: pass
if data: timerit(self.jsonRPC.notifyAll)(0.5,['upnext_data', binascii.hexlify(json.dumps(data).encode('utf-8')).decode('utf-8'), '%s.SIGNAL'%(ADDON_ID)])
def onAction(self, act):
actionId = act.getId()
self.log('onAction: actionId = %s'%(actionId))
self.closing = True
if actionId == ACTION_MOVE_UP: timerit(BUILTIN.executebuiltin)(0.1,['AlarmClock(up,Action(up),.5,true,false)'])
elif actionId == ACTION_MOVE_DOWN: timerit(BUILTIN.executebuiltin)(0.1,['AlarmClock(down,Action(down),.5,true,false)'])
elif actionId in ACTION_PREVIOUS_MENU: timerit(BUILTIN.executebuiltin)(0.1,['AlarmClock(back,Action(back),.5,true,false)'])
def onClose(self):
self.log('onClose')
self.closing = True

View File

@@ -0,0 +1,192 @@
# Copyright (C) 2024 Lunatixz
# This file is part of PseudoTV Live.
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
# https://github.com/xbmc/xbmc/blob/master/xbmc/input/actions/ActionIDs.h
# https://github.com/xbmc/xbmc/blob/master/xbmc/input/Key.h
# -*- coding: utf-8 -*-
from globals import *
from jsonrpc import JSONRPC
WH, WIN = BUILTIN.getResolution()
class OverlayTool(xbmcgui.WindowXMLDialog):
focusControl = None
focusCycle = None
focusCycleLST = []
focusCNTRLST = {}
lastActionTime = time.time()
posx, posy = 0, 0
def __init__(self, *args, **kwargs):
xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs)
self.log('__init__, args = %s, kwargs = %s'%(args,kwargs))
with BUILTIN.busy_dialog():
self.jsonRPC = JSONRPC()
if BUILTIN.getInfoBool('Playing','Player'): self.window = xbmcgui.Window(12005)
else: self.window = xbmcgui.Window(10000)
self.window_w, self.window_h = WH
self.advRule = (kwargs.get("ADV_RULES") or False)
self.focusIDX = (kwargs.get("Focus_IDX") or 1)
self.defaultView = {}#self.jsonRPC.getViewMode()
self.vinView = (kwargs.get("Vignette_VideoMode") or self.defaultView)
self.vinImage = (kwargs.get("Vignette_Image") or os.path.join(MEDIA_LOC,'overlays','ratio.png'))
self.channelBugColor = '0x%s'%((kwargs.get("ChannelBug_Color") or SETTINGS.getSetting('ChannelBug_Color')))
try:
self.autoBugX, self.autoBugY = abs(int(self.window_w // 9) - self.window_w) - 128, abs(int(self.window_h // 16) - self.window_h) - 128 #auto
self.channelBugX, self.channelBugY = eval(SETTINGS.getSetting("Channel_Bug_Position_XY")) #user
except:
self.channelBugX, self.channelBugY = self.autoBugX, self.autoBugY
try:
self.autoNextX, self.autoNextY = abs(int(self.window_w // 9)), abs(int(self.window_h // 16) - self.window_h) - 356 #auto
self.onNextX, self.onNextY = eval(kwargs.get("OnNext_Position_XY",SETTINGS.getSetting("OnNext_Position_XY")))
except: self.onNextX, self.onNextY = self.autoNextX, self.autoNextY
try:
# self.runActions(RULES_ACTION_OVERLAY_OPEN, self.sysInfo.get('citem',{}), inherited=self)
# if self.vinView != self.defaultView: timerit(self.jsonRPC.setViewMode)(0.1,[self.vinView])
if BUILTIN.getInfoBool('Playing','Player'): BUILTIN.executebuiltin('ActivateWindow(fullscreenvideo)')
self.doModal()
except Exception as e:
self.log("__init__, failed! %s"%(e), xbmc.LOGERROR)
self.close()
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def onInit(self):
if not BUILTIN.getInfoBool('IsFullscreen','System'):
DIALOG.okDialog(LANGUAGE(32097)%(BUILTIN.getInfoLabel('ScreenResolution','System')))
self.posx, self.posy = 0, 0
self.vignetteControl = xbmcgui.ControlImage(0, 0, self.window_w, self.window_h, self.vinImage, aspectRatio=0) #IDX 0
self.channelBug = xbmcgui.ControlImage(self.channelBugX, self.channelBugY, 128, 128, COLOR_LOGO, 2, self.channelBugColor) #IDX 1
self.onNext_Artwork = xbmcgui.ControlImage(self.onNextX, self.onNextY, 256, 128, COLOR_FANART, 0) #IDX 2
self.onNext_Text = xbmcgui.ControlTextBox(self.onNextX, (self.onNextY + 140), 960, 72, 'font27', '0xFFFFFFFF')
self.onNext_Text.setText('%s %s[CR]%s: %s'%(LANGUAGE(32104),ADDON_NAME,LANGUAGE(32116),ADDON_NAME))
self._addCntrl(self.vignetteControl)
self._addCntrl(self.channelBug)
self._addCntrl(self.onNext_Artwork)
self._addCntrl(self.onNext_Text, incl=False)
self.focusCycleLST.insert(0,self.focusCycleLST.pop(self.focusIDX))
self.focusCycle = cycle(self.focusCycleLST).__next__
self.focusControl = self.focusCycle()
self.switch(self.focusControl)
def _addCntrl(self, cntrl, incl=True):
self.log('_addCntrl cntrl = %s'%(cntrl))
self.addControl(cntrl)
cntrl.setVisible(True)
if incl and not cntrl in self.focusCycleLST: self.focusCycleLST.append(cntrl)
if not cntrl in self.focusCNTRLST: self.focusCNTRLST[cntrl] = cntrl.getX(),cntrl.getY()
def switch(self, cntrl=None):
self.log('switch cntrl = %s'%(cntrl))
if not self.focusCycle is None:
if cntrl is None: self.focusControl = self.focusCycle()
else: self.focusControl = cntrl
self._setFocus(self.focusControl)
for cntrl in self.focusCNTRLST:
if self.focusControl != cntrl: cntrl.setAnimations([('Conditional', 'effect=fade start=100 end=25 time=240 delay=160 condition=True reversible=False')])
else:
self.posx, self.posy = cntrl.getX(),cntrl.getY()
cntrl.setAnimations([('Conditional', 'effect=fade start=25 end=100 time=240 delay=160 condition=True reversible=False')])
def move(self, cntrl):
posx, posy = self.focusCNTRLST[cntrl]
if (self.posx != posx or self.posy != posy):
cntrl.setPosition(self.posx, self.posy)
if cntrl == self.onNext_Artwork:
self.onNext_Text.setPosition(self.posx, (self.posy + 140))
def save(self):
changes = {}
for cntrl in self.focusCNTRLST:
posx, posy = cntrl.getX(), cntrl.getY()
if cntrl == self.channelBug:
if (posx != self.channelBugX or posy != self.channelBugY):
changes[cntrl] = posx, posy, (posx == self.autoBugX & posy == self.autoBugY)
elif cntrl == self.onNext_Artwork:
if (posx != self.onNextX or posy != self.onNextY):
changes[cntrl] = posx, posy, (posx == self.autoNextX & posy == self.autoNextY)
if changes:
self.log('save, saving %s'%(changes))
if DIALOG.yesnoDialog(LANGUAGE(32096)):
for cntrl, value in list(changes.items()): self.set(cntrl,value[0],value[1],value[2])
# if self.vinView != self.defaultView: timerit(self.jsonRPC.setViewMode)(0.1,[self.defaultView])
self.close()
def set(self, cntrl, posx, posy, auto=False):
self.log('set, cntrl = %s, posx,posy = (%s,%s) %s? %s'%(cntrl, posx, posy, LANGUAGE(30022), auto))
if self.advRule: save = PROPERTIES.setProperty
else: save = SETTINGS.setSetting
if cntrl == self.channelBug:
if auto: save("Channel_Bug_Position_XY",LANGUAGE(30022))
else: save("Channel_Bug_Position_XY","(%s,%s)"%(posx, posy))
elif cntrl == self.onNext_Artwork:
if auto: save("OnNext_Position_XY",LANGUAGE(30022))
else: save("OnNext_Position_XY","(%s,%s)"%(posx, posy))
def _setFocus(self, cntrl):
self.log('_setFocus cntrl = %s'%(cntrl))
try: self.setFocus(cntrl)
except: pass
def _getFocus(self, cntrl):
self.log('_getFocus cntrl = %s'%(cntrl))
try: self.getFocus(cntrl)
except: pass
def onAction(self, act):
actionId = act.getId()
self.log('onAction: actionId = %s'%(actionId))
lastaction = time.time() - self.lastActionTime
# during certain times we just want to discard all input
if lastaction < 3 and lastaction > 1 and actionId not in ACTION_PREVIOUS_MENU:
self.log('Not allowing actions')
action = ACTION_INVALID
elif actionId in ACTION_SELECT_ITEM: self.switch(self.focusCycle())
elif actionId in ACTION_PREVIOUS_MENU: self.save()
else:
if actionId == ACTION_MOVE_UP: self.posy-=1
elif actionId == ACTION_MOVE_DOWN: self.posy+=1
elif actionId == ACTION_MOVE_LEFT: self.posx-=1
elif actionId == ACTION_MOVE_RIGHT: self.posx+=1
else: return
self.move((self.focusControl))

View File

@@ -0,0 +1,254 @@
# Copyright (C) 2024 Jason Anderson, Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
from globals import *
class AVIChunk:
def __init__(self):
self.empty()
def empty(self):
self.size = 0
self.fourcc = ''
self.datatype = 1
self.chunk = ''
def read(self, thefile):
data = thefile.readBytes(4)
try: self.size = struct.unpack('<i', data)[0]
except: self.size = 0
# Putting an upper limit on the chunk size, in case the file is corrupt
if self.size > 0 and self.size < 10000:
self.chunk = thefile.readBytes(self.size)
else:
self.chunk = ''
self.size = 0
class AVIList:
def __init__(self):
self.empty()
def empty(self):
self.size = 0
self.fourcc = ''
self.datatype = 2
def read(self, thefile):
data = thefile.readBytes(4)
self.size = struct.unpack('<i', data)[0]
try: self.size = struct.unpack('<i', data)[0]
except: self.size = 0
self.fourcc = thefile.read(4)
class AVIHeader:
def __init__(self):
self.empty()
def empty(self):
self.dwMicroSecPerFrame = 0
self.dwMaxBytesPerSec = 0
self.dwPaddingGranularity = 0
self.dwFlags = 0
self.dwTotalFrames = 0
self.dwInitialFrames = 0
self.dwStreams = 0
self.dwSuggestedBufferSize = 0
self.dwWidth = 0
self.dwHeight = 0
class AVIStreamHeader:
def __init__(self):
self.empty()
def empty(self):
self.fccType = ''
self.fccHandler = ''
self.dwFlags = 0
self.wPriority = 0
self.wLanguage = 0
self.dwInitialFrame = 0
self.dwScale = 0
self.dwRate = 0
self.dwStart = 0
self.dwLength = 0
self.dwSuggestedBuffer = 0
self.dwQuality = 0
self.dwSampleSize = 0
self.rcFrame = ''
class AVIParser:
def __init__(self):
self.Header = AVIHeader()
self.StreamHeader = AVIStreamHeader()
def determineLength(self, filename: str) -> int and float:
log("AVIParser: determineLength %s"%filename)
try: self.File = FileAccess.open(filename, "rb", None)
except:
log("AVIParser: Unable to open the file")
return 0
dur = int(self.readHeader())
self.File.close()
log('AVIParser: Duration is %s'%(dur))
return dur
def readHeader(self):
# AVI Chunk
data = self.getChunkOrList()
if data.datatype != 2:
log("AVIParser: Not an avi")
return 0
#todo fix
if data.fourcc[0:4] != "AVI ":
log("AVIParser: Wrong FourCC")
return 0
# Header List
data = self.getChunkOrList()
if data.fourcc != "hdrl":
log("AVIParser: Header not found")
return 0
# Header chunk
data = self.getChunkOrList()
if data.fourcc != 'avih':
log('Header chunk not found')
return 0
self.parseHeader(data)
# Stream list
data = self.getChunkOrList()
if self.Header.dwStreams > 10:
self.Header.dwStreams = 10
for i in range(self.Header.dwStreams):
if data.datatype != 2:
log("AVIParser: Unable to find streams")
return 0
listsize = data.size
# Stream chunk number 1, the stream header
data = self.getChunkOrList()
if data.datatype != 1:
log("AVIParser: Broken stream header")
return 0
self.StreamHeader.empty()
self.parseStreamHeader(data)
# If this is the video header, determine the duration
if self.StreamHeader.fccType == 'vids':
return self.getStreamDuration()
# If this isn't the video header, skip through the rest of these
# stream chunks
try:
if listsize - data.size - 12 > 0:
self.File.seek(listsize - data.size - 12, 1)
data = self.getChunkOrList()
except:
log("AVIParser: Unable to seek")
log("AVIParser: Video stream not found")
return 0
def getStreamDuration(self):
try:
return int(self.StreamHeader.dwLength / (float(self.StreamHeader.dwRate) / float(self.StreamHeader.dwScale)))
except:
return 0
def parseHeader(self, data):
try:
header = struct.unpack('<iiiiiiiiiiiiii', data.chunk)
self.Header.dwMicroSecPerFrame = header[0]
self.Header.dwMaxBytesPerSec = header[1]
self.Header.dwPaddingGranularity = header[2]
self.Header.dwFlags = header[3]
self.Header.dwTotalFrames = header[4]
self.Header.dwInitialFrames = header[5]
self.Header.dwStreams = header[6]
self.Header.dwSuggestedBufferSize = header[7]
self.Header.dwWidth = header[8]
self.Header.dwHeight = header[9]
except:
self.Header.empty()
log("AVIParser: Unable to parse the header")
def parseStreamHeader(self, data):
try:
self.StreamHeader.fccType = data.chunk[0:4].decode('utf-8')
self.StreamHeader.fccHandler = data.chunk[4:8].decode('utf-8')
header = struct.unpack('<ihhiiiiiiiid', data.chunk[8:])
self.StreamHeader.dwFlags = header[0]
self.StreamHeader.wPriority = header[1]
self.StreamHeader.wLanguage = header[2]
self.StreamHeader.dwInitialFrame = header[3]
self.StreamHeader.dwScale = header[4]
self.StreamHeader.dwRate = header[5]
self.StreamHeader.dwStart = header[6]
self.StreamHeader.dwLength = header[7]
self.StreamHeader.dwSuggestedBuffer = header[8]
self.StreamHeader.dwQuality = header[9]
self.StreamHeader.dwSampleSize = header[10]
self.StreamHeader.rcFrame = ''
except:
self.StreamHeader.empty()
log("AVIParser: Error reading stream header")
def getChunkOrList(self):
try: data = self.File.readBytes(4).decode('utf-8')
except: data = self.File.read(4)
if data == "RIFF" or data == "LIST":
dataclass = AVIList()
elif len(data) == 0:
dataclass = AVIChunk()
dataclass.datatype = 3
else:
dataclass = AVIChunk()
dataclass.fourcc = data
# Fill in the chunk or list info
dataclass.read(self.File)
return dataclass

View File

@@ -0,0 +1,31 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
from globals import *
class FFProbe:
def determineLength(self, filename: str) -> int and float :
try:
import ffmpeg
log("FFProbe: determineLength %s"%(filename))
dur = ffmpeg.probe(FileAccess.translatePath(filename))["format"]["duration"]
log('FFProbe: Duration is %s'%(dur))
return dur
except Exception as e:
log("FFProbe: failed! %s"%(e), xbmc.LOGERROR)
return 0

View File

@@ -0,0 +1,144 @@
# Copyright (C) 2024 Jason Anderson, Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
from globals import *
class FLVTagHeader:
def __init__(self):
self.tagtype = 0
self.datasize = 0
self.timestamp = 0
self.timestampext = 0
def readHeader(self, thefile):
try:
data = struct.unpack('B', thefile.readBytes(1))[0]
self.tagtype = (data & 0x1F)
self.datasize = struct.unpack('>H', thefile.readBytes(2))[0]
data = struct.unpack('>B', thefile.readBytes(1))[0]
self.datasize = (self.datasize << 8) | data
self.timestamp = struct.unpack('>H', thefile.readBytes(2))[0]
data = struct.unpack('>B', thefile.readBytes(1))[0]
self.timestamp = (self.timestamp << 8) | data
self.timestampext = struct.unpack('>B', thefile.readBytes(1))[0]
except:
self.tagtype = 0
self.datasize = 0
self.timestamp = 0
self.timestampext = 0
class FLVParser:
def __init__(self):
self.monitor = MONITOR()
def determineLength(self, filename: str) -> int and float:
log("FLVParser: determineLength %s"%filename)
try: self.File = FileAccess.open(filename, "rb", None)
except:
log("FLVParser: Unable to open the file")
return 0
if self.verifyFLV() == False:
log("FLVParser: Not a valid FLV")
self.File.close()
return 0
tagheader = self.findLastVideoTag()
if tagheader is None:
log("FLVParser: Unable to find a video tag")
self.File.close()
return 0
dur = int(self.getDurFromTag(tagheader))
self.File.close()
log("FLVParser: Duration is %s"%(dur))
return dur
def verifyFLV(self):
data = self.File.read(3)
if data != 'FLV':
return False
return True
def findLastVideoTag(self):
try:
self.File.seek(0, 2)
curloc = self.File.tell()
except:
log("FLVParser: Exception seeking in findLastVideoTag")
return None
# Go through a limited amount of the file before quiting
maximum = curloc - (2 * 1024 * 1024)
if maximum < 0:
maximum = 8
while not self.monitor.abortRequested() and curloc > maximum:
try:
self.File.seek(-4, 1)
data = int(struct.unpack('>I', self.File.readBytes(4))[0])
if data < 1:
log('FLVParser: Invalid packet data')
return None
if curloc - data <= 0:
log('FLVParser: No video packet found')
return None
self.File.seek(-4 - data, 1)
curloc = curloc - data
tag = FLVTagHeader()
tag.readHeader(self.File)
if tag.datasize <= 0:
log('FLVParser: Invalid packet header')
return None
if curloc - 8 <= 0:
log('FLVParser: No video packet found')
return None
self.File.seek(-8, 1)
log("FLVParser: detected tag type %s"%(tag.tagtype))
curloc = self.File.tell()
if tag.tagtype == 9:
return tag
except:
log('FLVParser: Exception in findLastVideoTag')
return None
return None
def getDurFromTag(self, tag):
tottime = tag.timestamp | (tag.timestampext << 24)
tottime = int(tottime / 1000)
return tottime

View File

@@ -0,0 +1,35 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
from globals import *
class Hachoir:
def determineLength(self, filename: str) -> int and float:
try:
meta = {}
from hachoir.parser import createParser
from hachoir.metadata import extractMetadata
log("Hachoir: determineLength %s"%(filename))
meta = extractMetadata(createParser(FileAccess.open(filename,'r')))
if not meta: raise Exception('No meta found')
dur = meta.get('duration').total_seconds()
log('Hachoir: Duration is %s'%(dur))
return dur
except Exception as e:
log("Hachoir: failed! %s\nmeta = %s"%(e,meta), xbmc.LOGERROR)
return 0

View File

@@ -0,0 +1,210 @@
# Copyright (C) 2024Jason Anderson, Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
from globals import *
class MKVParser:
monitor = xbmc.Monitor()
def determineLength(self, filename: str) -> int and float:
log("MKVParser: determineLength %s"%filename)
try: self.File = FileAccess.open(filename, "rb", None)
except:
log("MKVParser: Unable to open the file")
log(traceback.format_exc(), xbmc.LOGERROR)
return
size = self.findHeader()
if size == 0:
log('MKVParser: Unable to find the segment info')
dur = 0
else:
dur = int(round(self.parseHeader(size)))
log("MKVParser: Duration is %s"%(dur))
return dur
def parseHeader(self, size):
duration = 0
timecode = 0
fileend = self.File.tell() + size
datasize = 1
data = 1
while not self.monitor.abortRequested() and self.File.tell() < fileend and datasize > 0 and data > 0:
data = self.getEBMLId()
datasize = self.getDataSize()
if data == 0x2ad7b1:
timecode = 0
try:
for x in range(datasize):
timecode = (timecode << 8) + struct.unpack('B', self.getData(1))[0]
except:
timecode = 0
if duration != 0 and timecode != 0:
break
elif data == 0x4489:
try:
if datasize == 4:
duration = int(struct.unpack('>f', self.getData(datasize))[0])
else:
duration = int(struct.unpack('>d', self.getData(datasize))[0])
except:
log("MKVParser: Error getting duration in header, size is " + str(datasize))
duration = 0
if timecode != 0 and duration != 0:
break
else:
try:
self.File.seek(datasize, 1)
except:
log('MKVParser: Error while seeking')
return 0
if duration > 0 and timecode > 0:
dur = (duration * timecode) / 1000000000
return dur
return 0
def findHeader(self):
log("MKVParser: findHeader")
filesize = self.getFileSize()
if filesize == 0:
log("MKVParser: Empty file")
return 0
data = self.getEBMLId()
# Check for 1A 45 DF A3
if data != 0x1A45DFA3:
log("MKVParser: Not a proper MKV")
return 0
datasize = self.getDataSize()
try:
self.File.seek(datasize, 1)
except:
log('MKVParser: Error while seeking')
return 0
data = self.getEBMLId()
# Look for the segment header
while not self.monitor.abortRequested() and data != 0x18538067 and self.File.tell() < filesize and data > 0 and datasize > 0:
datasize = self.getDataSize()
try:
self.File.seek(datasize, 1)
except:
log('MKVParser: Error while seeking')
return 0
data = self.getEBMLId()
datasize = self.getDataSize()
data = self.getEBMLId()
# Find segment info
while not self.monitor.abortRequested() and data != 0x1549A966 and self.File.tell() < filesize and data > 0 and datasize > 0:
datasize = self.getDataSize()
try:
self.File.seek(datasize, 1)
except:
log('MKVParser: Error while seeking')
return 0
data = self.getEBMLId()
datasize = self.getDataSize()
if self.File.tell() < filesize:
return datasize
return 0
def getFileSize(self):
size = 0
try:
pos = self.File.tell()
self.File.seek(0, 2)
size = self.File.tell()
self.File.seek(pos, 0)
except:
pass
return size
def getData(self, datasize):
data = self.File.readBytes(datasize)
return data
def getDataSize(self):
data = self.File.readBytes(1)
try:
firstbyte = struct.unpack('>B', data)[0]
datasize = firstbyte
mask = 0xFFFF
for i in range(8):
if datasize >> (7 - i) == 1:
mask = mask ^ (1 << (7 - i))
break
datasize = datasize & mask
if firstbyte >> 7 != 1:
for i in range(1, 8):
datasize = (datasize << 8) + struct.unpack('>B', self.File.readBytes(1))[0]
if firstbyte >> (7 - i) == 1:
break
except:
datasize = 0
return datasize
def getEBMLId(self):
data = self.File.readBytes(1)
try:
firstbyte = struct.unpack('>B', data)[0]
ID = firstbyte
if firstbyte >> 7 != 1:
for i in range(1, 4):
ID = (ID << 8) + struct.unpack('>B', self.File.readBytes(1))[0]
if firstbyte >> (7 - i) == 1:
break
except:
ID = 0
return ID

View File

@@ -0,0 +1,185 @@
# Copyright (C) 2024 Jason Anderson, Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
from globals import *
class MP4DataBlock:
def __init__(self):
self.size = -1
self.boxtype = ''
self.data = ''
class MP4MovieHeader:
def __init__(self):
self.version = 0
self.flags = 0
self.created = 0
self.modified = 0
self.scale = 0
self.duration = 0
class MP4Parser:
def __init__(self):
self.MovieHeader = MP4MovieHeader()
self.monitor = MONITOR()
def determineLength(self, filename: str) -> int and float:
log("MP4Parser: determineLength %s"%filename)
try: self.File = FileAccess.open(filename, "rb", None)
except:
log("MP4Parser: Unable to open the file")
return 0
dur = self.readHeader()
if not dur:
log("MP4Parser - Using New Parser")
boxes = self.find_boxes(self.File)
# Sanity check that this really is a movie file.
if (boxes.get(b"ftyp",[-1])[0] == 0):
try:
moov_boxes = self.find_boxes(self.File, boxes[b"moov"][0] + 8, boxes[b"moov"][1])
trak_boxes = self.find_boxes(self.File, moov_boxes[b"trak"][0] + 8, moov_boxes[b"trak"][1])
udta_boxes = self.find_boxes(self.File, moov_boxes[b"udta"][0] + 8, moov_boxes[b"udta"][1])
dur = self.scan_mvhd(self.File, moov_boxes[b"mvhd"][0])
except Exception as e:
log("MP4Parser, failed! %s\nboxes = %s"%(e,boxes), xbmc.LOGERROR)
dur = 0
self.File.close()
log("MP4Parser: Duration is %s"%(dur))
return dur
def find_boxes(self, f, start_offset=0, end_offset=float("inf")):
"""Returns a dictionary of all the data boxes and their absolute starting
and ending offsets inside the mp4 file. Specify a start_offset and end_offset to read sub-boxes."""
s = struct.Struct("> I 4s")
boxes = {}
offset = start_offset
last_offset = -1
f.seek(offset, 0)
while not self.monitor.abortRequested() and offset < end_offset:
try:
if last_offset == offset: break
else: last_offset = offset
data = f.readBytes(8) # read box header
if data == b"": break # EOF
length, text = s.unpack(data)
f.seek(length - 8, 1) # skip to next box
boxes[text] = (offset, offset + length)
offset += length
except: pass
return boxes
def scan_mvhd(self, f, offset):
f.seek(offset, 0)
f.seek(8, 1) # skip box header
data = f.readBytes(1) # read version number
version = int.from_bytes(data, "big")
word_size = 8 if version == 1 else 4
f.seek(3, 1) # skip flags
f.seek(word_size * 2, 1) # skip dates
timescale = int.from_bytes(f.readBytes(4), "big")
if timescale == 0: timescale = 600
duration = int.from_bytes(f.readBytes(word_size), "big")
duration = round(duration / timescale)
return duration
def readHeader(self):
data = self.readBlock()
if data.boxtype != 'ftyp':
log("MP4Parser: No file block")
return 0
# Skip past the file header
try:
self.File.seek(data.size, 1)
except:
log('MP4Parser: Error while seeking')
return 0
data = self.readBlock()
while not self.monitor.abortRequested() and data.boxtype != 'moov' and data.size > 0:
try: self.File.seek(data.size, 1)
except:
log('MP4Parser: Error while seeking')
return 0
data = self.readBlock()
data = self.readBlock()
while not self.monitor.abortRequested() and data.boxtype != 'mvhd' and data.size > 0:
try: self.File.seek(data.size, 1)
except:
log('MP4Parser: Error while seeking')
return 0
data = self.readBlock()
self.readMovieHeader()
if self.MovieHeader.scale > 0 and self.MovieHeader.duration > 0:
return int(self.MovieHeader.duration / self.MovieHeader.scale)
return 0
def readMovieHeader(self):
try:
self.MovieHeader.version = struct.unpack('>b', self.File.readBytes(1))[0]
self.File.read(3) #skip flags for now
if self.MovieHeader.version == 1:
data = struct.unpack('>QQIQQ', self.File.readBytes(36))
else:
data = struct.unpack('>IIIII', self.File.readBytes(20))
self.MovieHeader.created = data[0]
self.MovieHeader.modified = data[1]
self.MovieHeader.scale = data[2]
self.MovieHeader.duration = data[3]
except:
self.MovieHeader.duration = 0
def readBlock(self):
box = MP4DataBlock()
try:
data = self.File.readBytes(4)
box.size = struct.unpack('>I', data)[0]
box.boxtype = self.File.read(4)
if box.size == 1:
box.size = struct.unpack('>q', self.File.readBytes(8))[0]
box.size -= 8
box.size -= 8
if box.boxtype == 'uuid':
box.boxtype = self.File.read(16)
box.size -= 16
except:
pass
return box

View File

@@ -0,0 +1,50 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
from globals import *
class MediaInfo:
def determineLength(self, filename: str) -> int and float:
try:
from pymediainfo import MediaInfo
dur = 0
mi = None
fileXML = filename.replace('.%s'%(filename.rsplit('.',1)[1]),'-mediainfo.xml')
if FileAccess.exists(fileXML):
log("MediaInfo: parsing XML %s"%(fileXML))
fle = FileAccess.open(fileXML, 'rb')
mi = MediaInfo(fle.read())
fle.close()
else:
log("MediaInfo: parsing %s"%(FileAccess.translatePath(filename)))
mi = MediaInfo.parse(FileAccess.translatePath(filename))
if not mi is None and mi.tracks:
for track in mi.tracks:
if track.track_type == 'General':
dur = track.duration / 1000
break
log("MediaInfo: determineLength %s Duration is %s"%(filename,dur))
return dur
except Exception as e:
log("MediaInfo: failed! %s"%(e), xbmc.LOGERROR)
return 0

View File

@@ -0,0 +1,31 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PseudoTV Live is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
from globals import *
class MoviePY:
def determineLength(self, filename: str) -> int and float:
try:
from moviepy.editor import VideoFileClip
log("MoviePY: determineLength %s"%(filename))
dur = VideoFileClip(FileAccess.translatePath(filename)).duration
log('MoviePY: Duration is %s'%(dur))
return dur
except Exception as e:
log("MoviePY: failed! %s"%(e), xbmc.LOGERROR)
return 0

Some files were not shown because too many files have changed in this diff Show More