Updated kodi settings on Lenovo
39
Kodi/Lenovo/addons/plugin.video.pseudotv.live/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||

|
||||
[](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:
|
||||
|
||||
[](https://github.com/PseudoTV/PseudoTV_Live/blob/master/LICENSE)
|
||||
[](https://www.codacy.com/app/PseudoTV/PseudoTV_Live/dashboard)
|
||||
[](https://github.com/PseudoTV/PseudoTV_Live/commits?author=Lunatixz)
|
||||
[](https://kodi.tv/download)
|
||||
[](https://kodi.tv/contribute/donate)
|
||||
[](https://www.patreon.com/pseudotv)
|
||||
[](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
|
||||
84
Kodi/Lenovo/addons/plugin.video.pseudotv.live/addon.xml
Normal 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>
|
||||
372
Kodi/Lenovo/addons/plugin.video.pseudotv.live/changelog.txt
Normal 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
|
||||
@@ -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": ""
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "",
|
||||
"type": "",
|
||||
"number": 0,
|
||||
"name": "",
|
||||
"logo": "",
|
||||
"path": [],
|
||||
"group": [],
|
||||
"rules": {},
|
||||
"catchup": "vod",
|
||||
"radio": false,
|
||||
"favorite": false
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"uuid": "",
|
||||
"channels": [],
|
||||
"imports": []
|
||||
}
|
||||
148
Kodi/Lenovo/addons/plugin.video.pseudotv.live/remotes/genres.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"]}]}]}}]}}
|
||||
|
||||
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
146
Kodi/Lenovo/addons/plugin.video.pseudotv.live/remotes/music.tmp
Normal 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":"Haven’t heard of drill music? Don’t worry you’re not alone – yet. With this being a growing underground genre, it’s making its way into the media’s 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, there’s 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, it’s taken on a new British format mostly in South London where it’s 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. It’s the fastest-growing music type across the world and rose in popularity with DJs like David Guetta, Calvin Harris and Tiesto leading the way. It’s 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 music’s 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. It’s 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 it’s 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.
|
||||
|
||||
It’s 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 it’s 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.
|
||||
|
||||
Rock’n’roll 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 country’s music has its own names and even sub-genres.
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": 0,
|
||||
"values": {
|
||||
"0": ""
|
||||
}
|
||||
}
|
||||
2221
Kodi/Lenovo/addons/plugin.video.pseudotv.live/remotes/seasons.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"servers": {
|
||||
"Example": {
|
||||
"id": "",
|
||||
"version": "",
|
||||
"uuid": "",
|
||||
"name": "",
|
||||
"host": "",
|
||||
"remotes": {
|
||||
},
|
||||
"settings": {
|
||||
},
|
||||
"enabled": false,
|
||||
"online": false,
|
||||
"updated": null
|
||||
}
|
||||
}
|
||||
}
|
||||
557
Kodi/Lenovo/addons/plugin.video.pseudotv.live/remotes/xmltv.dtd
Normal 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.) -->
|
||||
|
||||
130
Kodi/Lenovo/addons/plugin.video.pseudotv.live/remotes/xmltv.xsd
Normal 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>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 449 KiB |
|
After Width: | Height: | Size: 476 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 265 KiB |
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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, {}
|
||||
|
||||
@@ -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 = {"&": "&",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
">": ">",
|
||||
"<": "<"}
|
||||
|
||||
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
|
||||
@@ -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()
|
||||
@@ -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&musicvideos=true&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')))
|
||||
|
||||
|
||||
@@ -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')))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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/
|
||||
@@ -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, {}
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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')))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
1894
Kodi/Lenovo/addons/plugin.video.pseudotv.live/resources/lib/kodi.py
Normal 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()))
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||