Merge branch 'develop' into translations
This commit is contained in:
commit
a3ff330988
92 changed files with 2975 additions and 15787 deletions
|
@ -12,7 +12,7 @@ Thanks a ton for contributing to PlexKodiConnect!
|
||||||
|
|
||||||
## Translations
|
## Translations
|
||||||
|
|
||||||
* Want to help translate PlexKodiConnect? Then go [visit crowdin.com](https://crowdin.com/project/plexkodiconnect/invite)
|
* Want to help translate PlexKodiConnect? Then go [visit Transifex.com](https://www.transifex.com/croneter/pkc)
|
||||||
|
|
||||||
## Programming
|
## Programming
|
||||||
|
|
||||||
|
|
10
README.md
10
README.md
|
@ -1,5 +1,5 @@
|
||||||
[![stable version](https://img.shields.io/badge/stable_version-1.7.5-blue.svg?maxAge=60&style=flat) ](https://dl.bintray.com/croneter/PlexKodiConnect/bin/repository.plexkodiconnect/repository.plexkodiconnect-1.0.0.zip)
|
[![stable version](https://img.shields.io/badge/stable_version-1.7.7-blue.svg?maxAge=60&style=flat) ](https://dl.bintray.com/croneter/PlexKodiConnect/bin/repository.plexkodiconnect/repository.plexkodiconnect-1.0.0.zip)
|
||||||
[![beta version](https://img.shields.io/badge/beta_version-1.7.5-red.svg?maxAge=60&style=flat) ](https://dl.bintray.com/croneter/PlexKodiConnect_BETA/bin-BETA/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.0.zip)
|
[![beta version](https://img.shields.io/badge/beta_version-1.7.7-red.svg?maxAge=60&style=flat) ](https://dl.bintray.com/croneter/PlexKodiConnect_BETA/bin-BETA/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.0.zip)
|
||||||
|
|
||||||
[![Installation](https://img.shields.io/badge/wiki-installation-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/Installation)
|
[![Installation](https://img.shields.io/badge/wiki-installation-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/Installation)
|
||||||
[![FAQ](https://img.shields.io/badge/wiki-FAQ-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/faq)
|
[![FAQ](https://img.shields.io/badge/wiki-FAQ-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/faq)
|
||||||
|
@ -17,7 +17,7 @@ Have a look at [some screenshots](https://github.com/croneter/PlexKodiConnect/wi
|
||||||
|
|
||||||
### Please Help Translating
|
### Please Help Translating
|
||||||
|
|
||||||
Please help translate PlexKodiConnect into your language: [crowdin.com](https://crowdin.com/project/plexkodiconnect/invite)
|
Please help translate PlexKodiConnect into your language: [Transifex.com](https://www.transifex.com/croneter/pkc)
|
||||||
|
|
||||||
|
|
||||||
### Content
|
### Content
|
||||||
|
@ -63,7 +63,9 @@ PKC synchronizes your media from your Plex server to the native Kodi database. H
|
||||||
+ Danish, thanks @FIGHT
|
+ Danish, thanks @FIGHT
|
||||||
+ Italian, thanks @nikkux, @chicco83
|
+ Italian, thanks @nikkux, @chicco83
|
||||||
+ Dutch, thanks @mvanbaak
|
+ Dutch, thanks @mvanbaak
|
||||||
+ [Please help translating](https://crowdin.com/project/plexkodiconnect/invite)
|
+ Chinese Traditional, thanks @old2tan
|
||||||
|
+ Chinese Simplified, thanks @everdream
|
||||||
|
+ [Please help translating](https://www.transifex.com/croneter/pkc)
|
||||||
|
|
||||||
### Download and Installation
|
### Download and Installation
|
||||||
|
|
||||||
|
|
57
addon.xml
57
addon.xml
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<addon id="plugin.video.plexkodiconnect" name="PlexKodiConnect" version="1.7.5" provider-name="croneter">
|
<addon id="plugin.video.plexkodiconnect" name="PlexKodiConnect" version="1.7.7" provider-name="croneter">
|
||||||
<requires>
|
<requires>
|
||||||
<import addon="xbmc.python" version="2.1.0"/>
|
<import addon="xbmc.python" version="2.1.0"/>
|
||||||
<import addon="script.module.requests" version="2.3.0" />
|
<import addon="script.module.requests" version="2.3.0" />
|
||||||
|
@ -18,27 +18,48 @@
|
||||||
</extension>
|
</extension>
|
||||||
<extension point="xbmc.addon.metadata">
|
<extension point="xbmc.addon.metadata">
|
||||||
<platform>all</platform>
|
<platform>all</platform>
|
||||||
<language></language>
|
|
||||||
<license>GNU GENERAL PUBLIC LICENSE. Version 2, June 1991</license>
|
<license>GNU GENERAL PUBLIC LICENSE. Version 2, June 1991</license>
|
||||||
<forum>https://forums.plex.tv</forum>
|
<forum>https://forums.plex.tv</forum>
|
||||||
<website>https://github.com/croneter/PlexKodiConnect</website>
|
<website>https://github.com/croneter/PlexKodiConnect</website>
|
||||||
<email></email>
|
<email></email>
|
||||||
<source>https://github.com/croneter/PlexKodiConnect</source>
|
<source>https://github.com/croneter/PlexKodiConnect</source>
|
||||||
<summary lang="en">Native Integration of Plex into Kodi</summary>
|
<summary lang="en_GB">Native Integration of Plex into Kodi</summary>
|
||||||
<summary lang="en_gb">Native Integration of Plex into Kodi</summary>
|
<summary lang="en_US">Native Integration of Plex into Kodi</summary>
|
||||||
<summary lang="en_us">Native Integration of Plex into Kodi</summary>
|
<summary lang="en_NZ">Native Integration of Plex into Kodi</summary>
|
||||||
<summary lang="cs">Úplná integrace Plexu do Kodi</summary>
|
<summary lang="en_AU">Native Integration of Plex into Kodi</summary>
|
||||||
<summary lang="de">Komplette Integration von Plex in Kodi</summary>
|
<summary lang="cs_CZ">Úplná integrace Plexu do Kodi</summary>
|
||||||
<summary lang="es">Native Integration of Plex into Kodi</summary>
|
<summary lang="de_DE">Komplette Integration von Plex in Kodi</summary>
|
||||||
<summary lang="dk">Indbygget Integration af Plex i Kodi</summary>
|
<summary lang="da_DK">Indbygget Integration af Plex i Kodi</summary>
|
||||||
<summary lang="nl">Directe integratie van Plex in Kodi</summary>
|
<summary lang="nl_NL">Directe integratie van Plex in Kodi</summary>
|
||||||
<description lang="en">Connect Kodi to your Plex Media Server. This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases (as this plugin directly changes them). Use at your own risk!</description>
|
<summary lang="zh_TW">將Plex直接內嵌至Kodi</summary>
|
||||||
<description lang="en_gb">Connect Kodi to your Plex Media Server. This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases (as this plugin directly changes them). Use at your own risk!</description>
|
<description lang="en_GB">Connect Kodi to your Plex Media Server. This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases (as this plugin directly changes them).</description>
|
||||||
<description lang="en_us">Connect Kodi to your Plex Media Server. This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases (as this plugin directly changes them). Use at your own risk!</description>
|
<description lang="en_US">Connect Kodi to your Plex Media Server. This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases (as this plugin directly changes them).</description>
|
||||||
<description lang="cs">Připojte Kodi ke svému Plex Media Serveru. Tento doplněk předpokládá, že spravujete veškerá svá videa pomocí Plexu (nikoliv pomocí Kodi). Můžete přijít o data uložená ve video a hudební databázi Kodi (tento doplněk je přímo mění). Používejte na vlastní nebezpečí!</description>
|
<description lang="en_NZ">Connect Kodi to your Plex Media Server. This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases (as this plugin directly changes them).</description>
|
||||||
<description lang="de">Verbindet Kodi mit deinem Plex Media Server. Dieses Addon geht davon aus, dass du all deine Videos mit Plex verwaltest (und keine direkt mit Kodi). Du wirst möglicherweise Daten verlieren, die bereits in der Kodi Video- und/oder Musik-Datenbank gespeichert sind (da dieses Addon beide Datenbanken direkt verändert). Verwende auf eigene Gefahr!</description>
|
<description lang="en_AU">Connect Kodi to your Plex Media Server. This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases (as this plugin directly changes them).</description>
|
||||||
<description lang="es">Connect Kodi to your Plex Media Server. This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases (as this plugin directly changes them). Use at your own risk!</description>
|
<description lang="cs_CZ">Připojte Kodi ke svému Plex Media Serveru. Tento doplněk předpokládá, že spravujete veškerá svá videa pomocí Plexu (nikoliv pomocí Kodi). Můžete přijít o data uložená ve video a hudební databázi Kodi (tento doplněk je přímo mění).</description>
|
||||||
<description lang="dk">Tilslut Kodi til din Plex Media Server. Dette plugin forudsætter, at du administrere alle dine videoer med Plex (og ikke med Kodi). Du kan miste data som allerede er gemt i Kodi video og musik-databaser (dette plugin ændrer direkte i dem). Brug på eget ansvar!</description>
|
<description lang="de_DE">Verbindet Kodi mit deinem Plex Media Server. Dieses Addon geht davon aus, dass du all deine Videos mit Plex verwaltest (und keine direkt mit Kodi). Du wirst möglicherweise Daten verlieren, die bereits in der Kodi Video- und/oder Musik-Datenbank gespeichert sind (da dieses Addon beide Datenbanken direkt verändert).</description>
|
||||||
<description lang="nl">Verbind Kodi met je Plex Media Server. Deze plugin gaat ervan uit dat je al je video's met Plex (en niet met Kodi) beheerd. Je kunt gegevens reeds opgeslagen in de databases voor video en muziek van Kodi (deze plugin wijzigt deze gegevens direct) verliezen. Gebruik op eigen risico!</description>
|
<description lang="da_DK">Tilslut Kodi til din Plex Media Server. Dette plugin forudsætter, at du administrere alle dine videoer med Plex (og ikke med Kodi). Du kan miste data som allerede er gemt i Kodi video og musik-databaser (dette plugin ændrer direkte i dem).</description>
|
||||||
|
<description lang="nl_NL">Verbind Kodi met je Plex Media Server. Deze plugin gaat ervan uit dat je al je video's met Plex (en niet met Kodi) beheerd. Je kunt gegevens reeds opgeslagen in de databases voor video en muziek van Kodi (deze plugin wijzigt deze gegevens direct) verliezen.</description>
|
||||||
|
<description lang="zh_TW">連接Kodi至您的Plex媒體伺服器.這個外掛程式假設你使用Plex伺服器管理您所有的媒體(完全沒用Kodi管理).您可能會失去已經儲存在Kodi資料庫中的視頻及音樂的資料(這個外掛程式會直接更改它們).</description>
|
||||||
|
<disclaimer lang="en_GB">Use at your own risk</disclaimer>
|
||||||
|
<disclaimer lang="en_US">Use at your own risk</disclaimer>
|
||||||
|
<disclaimer lang="en_NZ">Use at your own risk</disclaimer>
|
||||||
|
<disclaimer lang="en_AU">Use at your own risk</disclaimer>
|
||||||
|
<disclaimer lang="cs_CZ">Používejte na vlastní nebezpečí</disclaimer>
|
||||||
|
<disclaimer lang="de_DE">Benutze auf eigene Gefahr</disclaimer>
|
||||||
|
<disclaimer lang="da_DK">Brug på eget ansvar</disclaimer>
|
||||||
|
<disclaimer lang="nl_NL">Gebruik op eigen risico</disclaimer>
|
||||||
|
<disclaimer lang="zh_TW">使用風險由您自己承擔</disclaimer>
|
||||||
|
<news>version 1.7.7
|
||||||
|
- Chinese Traditional, thanks @old2tan
|
||||||
|
- Chinese Simplified, thanks @everdream
|
||||||
|
- Browse by folder: also sort by Date Added
|
||||||
|
- Update addon.xml
|
||||||
|
|
||||||
|
version 1.7.6
|
||||||
|
- Hotfix: Revert Cache missing artwork on PKC startup. This should help with slow PKC startup, videos not being started, lagging PKC, etc.
|
||||||
|
|
||||||
|
version 1.7.5
|
||||||
|
- Dutch translation, thanks @mvanbaak</news>
|
||||||
</extension>
|
</extension>
|
||||||
</addon>
|
</addon>
|
||||||
|
|
|
@ -1,3 +1,12 @@
|
||||||
|
version 1.7.7
|
||||||
|
- Chinese Traditional, thanks @old2tan
|
||||||
|
- Chinese Simplified, thanks @everdream
|
||||||
|
- Browse by folder: also sort by Date Added
|
||||||
|
- Update addon.xml
|
||||||
|
|
||||||
|
version 1.7.6
|
||||||
|
- Hotfix: Revert Cache missing artwork on PKC startup. This should help with slow PKC startup, videos not being started, lagging PKC, etc.
|
||||||
|
|
||||||
version 1.7.5
|
version 1.7.5
|
||||||
- Dutch translation, thanks @mvanbaak
|
- Dutch translation, thanks @mvanbaak
|
||||||
|
|
||||||
|
|
521
resources/language/Chinese (Simplified)/strings.xml
Normal file
521
resources/language/Chinese (Simplified)/strings.xml
Normal file
|
@ -0,0 +1,521 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||||
|
<strings>
|
||||||
|
<!-- Add-on settings -->
|
||||||
|
<string id="29999">PlexKodiConnect</string>
|
||||||
|
<string id="30000">服务器地址(IP)</string><!-- Verified -->
|
||||||
|
<string id="30002">首选播放模式</string><!-- Verified -->
|
||||||
|
<string id="30004">日志等级</string><!-- Verified -->
|
||||||
|
<string id="30005">用户名 </string>
|
||||||
|
<string id="30006">密码 </string>
|
||||||
|
<string id="30007">网络用户名 </string>
|
||||||
|
<string id="30008">网络密码 </string>
|
||||||
|
<string id="30009">转码 </string>
|
||||||
|
<string id="30010">启用性能分析</string>
|
||||||
|
<string id="30011">本地缓存系统</string>
|
||||||
|
<string id="30012">确认</string>
|
||||||
|
<string id="30013">不再显示</string>
|
||||||
|
|
||||||
|
<string id="30014">连接</string>
|
||||||
|
<string id="30015">网络</string>
|
||||||
|
<string id="30016">设备名</string>
|
||||||
|
<string id="30017">Plex媒体服务器未授权</string>
|
||||||
|
|
||||||
|
<string id="30022">高级</string>
|
||||||
|
<string id="30024">用户名</string><!-- Verified -->
|
||||||
|
|
||||||
|
<string id="30025">如Plex媒体服务器离线则显示消息</string>
|
||||||
|
|
||||||
|
<string id="30030">端口号</string><!-- Verified -->
|
||||||
|
<string id="30031">我拥有这台Plex媒体服务器</string>
|
||||||
|
<string id="30036">要显示的最近电影数量</string>
|
||||||
|
<string id="30037">要显示的最近电视剧集数量</string>
|
||||||
|
<string id="30035">要显示的最近音乐专辑数量</string>
|
||||||
|
<string id="30038">播放开始后标记为已观看</string>
|
||||||
|
<string id="30039">为剧集设置每季海报</string>
|
||||||
|
|
||||||
|
<string id="30040">流派筛选器...</string>
|
||||||
|
<string id="30041">从此处播放所有</string>
|
||||||
|
<string id="30042">刷新</string>
|
||||||
|
<string id="30043">删除</string>
|
||||||
|
<string id="30046">添加电影到CouchPotato</string>
|
||||||
|
|
||||||
|
<string id="30044">用户名/密码不正确</string>
|
||||||
|
<string id="30045">找不到用户名</string>
|
||||||
|
|
||||||
|
<string id="30052">删除中</string>
|
||||||
|
<string id="30053">等待服务器删除</string>
|
||||||
|
|
||||||
|
<string id="30059">服务器默认值</string>
|
||||||
|
<string id="30060">标题</string>
|
||||||
|
<string id="30061">年份</string>
|
||||||
|
<string id="30062">首播日期</string>
|
||||||
|
<string id="30063">创建日期</string>
|
||||||
|
<string id="30064">专家评分</string>
|
||||||
|
<string id="30065">社区评分</string>
|
||||||
|
<string id="30066">播放次数</string>
|
||||||
|
<string id="30067">预算</string>
|
||||||
|
<!-- Runtime added as 30226 below -->
|
||||||
|
|
||||||
|
<string id="30068">排序方式</string>
|
||||||
|
|
||||||
|
<string id="30069">无</string>
|
||||||
|
<string id="30070">动作</string>
|
||||||
|
<string id="30071">冒险</string>
|
||||||
|
<string id="30072">动画</string>
|
||||||
|
<string id="30073">犯罪</string>
|
||||||
|
<string id="30074">喜剧</string>
|
||||||
|
<string id="30075">纪录片</string>
|
||||||
|
<string id="30076">剧情</string>
|
||||||
|
<string id="30077">幻想</string>
|
||||||
|
<string id="30078">外语</string>
|
||||||
|
<string id="30079">历史</string>
|
||||||
|
<string id="30080">恐怖</string>
|
||||||
|
<string id="30081">音乐</string>
|
||||||
|
<string id="30082">音乐剧</string>
|
||||||
|
<string id="30083">悬疑</string>
|
||||||
|
<string id="30084">浪漫</string>
|
||||||
|
<string id="30085">科幻</string>
|
||||||
|
<string id="30086">短片</string>
|
||||||
|
<string id="30087">悬念</string>
|
||||||
|
<string id="30088">惊悚</string>
|
||||||
|
<string id="30089">西部</string>
|
||||||
|
|
||||||
|
<string id="30090">流派筛选器</string>
|
||||||
|
<string id="30091">文件删除确认</string><!-- Verified -->
|
||||||
|
<string id="30092">删除此项目吗?此操作将删除媒体和关联的数据文件。</string>
|
||||||
|
|
||||||
|
<string id="30093">标记为已观看</string>
|
||||||
|
<string id="30094">标记为未观看</string>
|
||||||
|
<string id="30095">添加到收藏夹</string>
|
||||||
|
<string id="30096">从收藏夹移除</string>
|
||||||
|
<string id="30097">排序方式</string>
|
||||||
|
<string id="30098">降序排序</string>
|
||||||
|
<string id="30099">升序排序</string>
|
||||||
|
<string id="30100">显示人物</string>
|
||||||
|
|
||||||
|
<!-- resume dialog -->
|
||||||
|
<string id="30105">继续</string>
|
||||||
|
<string id="30106">从此处继续</string>
|
||||||
|
<string id="30107">从头开始</string>
|
||||||
|
|
||||||
|
<string id="30110">用户界面</string>
|
||||||
|
<string id="30111">包括串流信息</string>
|
||||||
|
<string id="30112">包括人物</string>
|
||||||
|
<string id="30113">包括概述</string>
|
||||||
|
<string id="30114">提供播放后删除</string><!-- Verified -->
|
||||||
|
<string id="30115">供剧集</string><!-- Verified -->
|
||||||
|
<string id="30116">供电影</string><!-- Verified -->
|
||||||
|
<string id="30117">背景刷新率(秒)</string>
|
||||||
|
<string id="30118">添加恢复比率</string>
|
||||||
|
<string id="30119">添加剧集数量</string>
|
||||||
|
<string id="30120">显示加载进度</string>
|
||||||
|
<string id="30121">正加载内容</string>
|
||||||
|
<string id="30122">检索数据</string>
|
||||||
|
<string id="30125">完成</string>
|
||||||
|
<string id="30126">正处理项目 </string>
|
||||||
|
<string id="30128">播放错误</string>
|
||||||
|
<string id="30129">此项目无法播放</string>
|
||||||
|
<string id="30130">检测到本地路径</string>
|
||||||
|
<string id="30131">您的MB3服务器包含本地路径。请更改服务器路径至UNC或在XBMB3C设置里将“从串流播放”值更改为true。路径: </string>
|
||||||
|
<string id="30132">警告</string>
|
||||||
|
<string id="30133">启用调试</string>
|
||||||
|
<string id="30134">这将影响性能</string>
|
||||||
|
<string id="30135">错误</string>
|
||||||
|
<string id="30136">监视服务未运行</string>
|
||||||
|
<string id="30137">如刚安装请重启Kodi</string>
|
||||||
|
<string id="30138">搜索</string>
|
||||||
|
|
||||||
|
<string id="30139">启用主题音乐(须重启)</string>
|
||||||
|
<string id="30140"> 循环主题音乐</string>
|
||||||
|
<string id="30141">启用背景图(须重启)</string>
|
||||||
|
<string id="30142">服务</string>
|
||||||
|
|
||||||
|
<string id="30143">视频比特率高于则总是转码</string>
|
||||||
|
|
||||||
|
<string id="30150">皮肤不支持设置视图</string>
|
||||||
|
<string id="30151">选中项目动作(须重启)</string>
|
||||||
|
|
||||||
|
<string id="30156">通过标题排序下一步</string>
|
||||||
|
<string id="30157">启用增强图像(如Coverart)</string><!-- Verified -->
|
||||||
|
<string id="30158">元数据</string>
|
||||||
|
<string id="30159">插图</string>
|
||||||
|
<string id="30160">如须转码视频质量</string><!-- Verified -->
|
||||||
|
|
||||||
|
<string id="30161">启用建议的加载器(须重启)</string>
|
||||||
|
<string id="30162">添加季数</string>
|
||||||
|
<string id="30163">平铺每季</string>
|
||||||
|
|
||||||
|
<string id="30164">直接播放 - HTTP</string>
|
||||||
|
<string id="30165">直接播放</string>
|
||||||
|
<string id="30166">转码</string>
|
||||||
|
<string id="30167">服务器检测成功</string>
|
||||||
|
<string id="30168">找到服务器</string>
|
||||||
|
<string id="30169">地址: </string>
|
||||||
|
|
||||||
|
<!-- Video nodes -->
|
||||||
|
<string id="30170">最近添加的电视剧</string><!-- Verified -->
|
||||||
|
<string id="30171">观看中的电视剧</string><!-- Verified -->
|
||||||
|
<string id="30172">所有音乐</string>
|
||||||
|
<string id="30173">频道</string><!-- Verified -->
|
||||||
|
<string id="30174">最近添加</string><!-- Verified -->
|
||||||
|
<string id="30175">最近添加剧集</string><!-- Verified -->
|
||||||
|
<string id="30176">最近添加专辑</string>
|
||||||
|
<string id="30177">观看中的电影</string><!-- Verified -->
|
||||||
|
<string id="30178">观看中的剧集</string><!-- Verified -->
|
||||||
|
<string id="30179">下一集</string><!-- Verified -->
|
||||||
|
<string id="30180">喜欢的电影</string><!-- Verified -->
|
||||||
|
<string id="30181">喜欢的节目</string><!-- Verified -->
|
||||||
|
<string id="30182">喜欢的剧集</string>
|
||||||
|
<string id="30183">播放最多的专辑</string>
|
||||||
|
<string id="30184">即将来临的电视</string>
|
||||||
|
<string id="30185">盒装套装</string>
|
||||||
|
<string id="30186">预告片</string>
|
||||||
|
<string id="30187">音乐视频</string>
|
||||||
|
<string id="30188">照片</string>
|
||||||
|
<string id="30189">未观看的电影</string><!-- Verified -->
|
||||||
|
<string id="30190">电影流派</string>
|
||||||
|
<string id="30191">电影制片厂</string>
|
||||||
|
<string id="30192">电影演员</string>
|
||||||
|
<string id="30193">未观看的剧集</string>
|
||||||
|
<string id="30194">电视流派</string>
|
||||||
|
<string id="30195">电视网络</string>
|
||||||
|
<string id="30196">电视演员</string>
|
||||||
|
<string id="30197">播放列表</string>
|
||||||
|
<string id="30198">搜索</string>
|
||||||
|
<string id="30199">设置视图</string>
|
||||||
|
|
||||||
|
<string id="30200">选择用户</string>
|
||||||
|
<string id="30201">分析已启用</string>
|
||||||
|
<string id="30202">完成测试后请记得关闭</string>
|
||||||
|
<string id="30203">插图旋转线索错误</string>
|
||||||
|
<string id="30204">无法连接到服务器</string>
|
||||||
|
<string id="30205">载入目录选项线索错误</string>
|
||||||
|
|
||||||
|
<string id="30206">启用播放列表载入(须重启)</string>
|
||||||
|
|
||||||
|
<string id="30207">歌曲</string>
|
||||||
|
<string id="30208">专辑</string>
|
||||||
|
<string id="30209">专辑艺人</string>
|
||||||
|
<string id="30210">艺人</string>
|
||||||
|
<string id="30211">音乐流派</string>
|
||||||
|
|
||||||
|
<string id="30212">启用主题视频(须重启)</string>
|
||||||
|
<string id="30213"> - 循环主题视频</string>
|
||||||
|
|
||||||
|
<string id="30216">自动播放当季剩余剧集</string>
|
||||||
|
<string id="30218">压缩插图</string>
|
||||||
|
<string id="30220">最近的 </string>
|
||||||
|
<string id="30221">进行中 </string>
|
||||||
|
<string id="30222">接下来 </string>
|
||||||
|
<string id="30223">用户视图</string>
|
||||||
|
<string id="30224">报告Metrics</string>
|
||||||
|
<string id="30225">使用Kodi排序</string>
|
||||||
|
<string id="30226">运行时长</string>
|
||||||
|
|
||||||
|
<string id="30227">随机</string>
|
||||||
|
<string id="30228">最近发行</string>
|
||||||
|
<string id="30229">随机项目</string><!-- Verified -->
|
||||||
|
<string id="30230">推荐</string><!-- Verified -->
|
||||||
|
|
||||||
|
<string id="30235">附加</string><!-- Verified -->
|
||||||
|
<string id="30236">同步主题音乐</string>
|
||||||
|
<string id="30237">同步附加Fanart</string>
|
||||||
|
<string id="30238">同步电影盒装套装</string>
|
||||||
|
|
||||||
|
<string id="30239">[黄色]重置本地KodI数据库[/COLOR]</string><!-- Verified -->
|
||||||
|
<string id="30240">启用已观看/继续观看状态同步</string>
|
||||||
|
<string id="30241">DB同步标示:</string>
|
||||||
|
<string id="30242">播放次数同步标示:</string>
|
||||||
|
<string id="30243">启用HTTPS</string><!-- Verified -->
|
||||||
|
<string id="30245">强制转码编码器</string>
|
||||||
|
|
||||||
|
<string id="30246">启用类Netflix下一步通知</string>
|
||||||
|
<string id="30247"> - 节目结束前多少秒显示通知</string>
|
||||||
|
<string id="30248">播放/选择动作时显示Emby信息对话</string>
|
||||||
|
<string id="30249">启用启动时服务器连接消息</string><!-- Verified -->
|
||||||
|
|
||||||
|
<string id="30251">最近添加的家庭录像</string><!-- Verified -->
|
||||||
|
<string id="30252">最近添加的照片</string><!-- Verified -->
|
||||||
|
<string id="30253">喜欢的家庭录像</string><!-- Verified -->
|
||||||
|
<string id="30254">喜欢的照片</string><!-- Verified -->
|
||||||
|
<string id="30255">喜欢的专辑</string>
|
||||||
|
|
||||||
|
<string id="30256">最近添加的音乐视频</string><!-- Verified -->
|
||||||
|
<string id="30257">播放中的音乐视频</string><!-- Verified -->
|
||||||
|
<string id="30258">未观看的音乐视频</string><!-- Verified -->
|
||||||
|
|
||||||
|
<!-- Default views -->
|
||||||
|
<string id="30300">激活</string>
|
||||||
|
<string id="30301">清除设置</string>
|
||||||
|
<string id="30302">电影</string>
|
||||||
|
<string id="30303">盒装套装</string>
|
||||||
|
<string id="30304">预告片</string>
|
||||||
|
<string id="30305">系列</string>
|
||||||
|
<string id="30306">季</string>
|
||||||
|
<string id="30307">剧集</string>
|
||||||
|
<string id="30308">音乐艺人</string>
|
||||||
|
<string id="30309">音乐专辑</string>
|
||||||
|
<string id="30310">音乐视频</string>
|
||||||
|
<string id="30311">音乐曲目</string>
|
||||||
|
<string id="30312">频道</string>
|
||||||
|
|
||||||
|
<!-- contextmenu -->
|
||||||
|
<string id="30401">Plex选项</string>
|
||||||
|
<string id="30402">取消喜欢对此项目</string>
|
||||||
|
<string id="30403">喜欢此项目</string>
|
||||||
|
<string id="30404">踩这个项目</string>
|
||||||
|
<string id="30405">添加到Plex收藏夹</string>
|
||||||
|
<string id="30406">从Plex收藏夹移除</string>
|
||||||
|
<string id="30407">设置自定义歌曲打分</string>
|
||||||
|
<string id="30408">Plex插件设置</string>
|
||||||
|
<string id="30409">从服务器上删除此项目</string>
|
||||||
|
<string id="30410">刷新此项目</string>
|
||||||
|
<string id="30411">设置自定义歌曲打分(0-5)</string>
|
||||||
|
<string id="30412">强制转码</string>
|
||||||
|
<string id="30413">在Kodi中启用Plex上下文菜单</string>
|
||||||
|
<string id="30414">无法删除此Plex项目。Plex媒体服务器已启用项目删除了吗?</string>
|
||||||
|
<string id="30415">通过PMS开始播放</string>
|
||||||
|
<string id="30416">Plex服务器配置</string>
|
||||||
|
|
||||||
|
<!-- add-on settings -->
|
||||||
|
<string id="30500">验证主机SSL证书(更安全)</string>
|
||||||
|
<string id="30501">客户端SSL证书</string>
|
||||||
|
<string id="30502">使用备用地址</string>
|
||||||
|
<string id="30503">备用服务器地址</string>
|
||||||
|
<string id="30504">使用备用设备名称</string>
|
||||||
|
<string id="30505">[黄色]重置登录尝试[/COLOR]</string>
|
||||||
|
<string id="30506">同步选项</string>
|
||||||
|
<string id="30507">显示同步进度</string>
|
||||||
|
<string id="30508">同步空电视节目</string>
|
||||||
|
<string id="30509">启用音乐库</string>
|
||||||
|
<string id="30510">直接串流音乐库</string>
|
||||||
|
<string id="30511">播放模式</string>
|
||||||
|
<string id="30512">强制缓存插图</string>
|
||||||
|
<string id="30513">限制插图缓存线程(rpi下推荐)</string>
|
||||||
|
<string id="30514">启用快速启动(须服务器插件)</string>
|
||||||
|
<string id="30515">一次从服务器请求最大项目数</string>
|
||||||
|
<string id="30516">回放</string>
|
||||||
|
<string id="30517">[黄色]输入网络凭证[/COLOR]</string>
|
||||||
|
<string id="30518">启用Plex预告片(须Plexpass)</string>
|
||||||
|
<string id="30519">要求播放预告片</string>
|
||||||
|
<string id="30520">为上下文菜单跳过Plex删除确认(须自行承担风险)</string>
|
||||||
|
<string id="30521">跳回继续(秒)</string>
|
||||||
|
<string id="30522">强制h265/HEVC转码</string>
|
||||||
|
<string id="30523">音乐元数据选项(不兼容直接串流)</string>
|
||||||
|
<string id="30524">从文件里直接导入音乐曲目打分</string>
|
||||||
|
<string id="30525">将音乐曲目打分转换为Emby排名</string>
|
||||||
|
<string id="30526">允许曲目文件打分更新</string>
|
||||||
|
<string id="30527">下一集忽略特集</string>
|
||||||
|
<string id="30528">永久用户添加会话</string>
|
||||||
|
<string id="30529">启动延迟 (秒)</string>
|
||||||
|
<string id="30530">启用服务器重启消息</string>
|
||||||
|
<string id="30531">启用新内容通知</string>
|
||||||
|
<string id="30532">视频库弹出时长(秒)</string>
|
||||||
|
<string id="30533">音乐库弹出时长(秒)</string>
|
||||||
|
<string id="30534">服务器消息</string>
|
||||||
|
<string id="30535">[黄色]生成新的唯一设备ID(e.g. 当克隆Kodi时)[/COLOR]</string>
|
||||||
|
<string id="30536">Kodi重启后用户须登录</string>
|
||||||
|
<string id="30537">如有任何更改请重启KODI</string>
|
||||||
|
<string id="30538">必须完整重新同步</string>
|
||||||
|
<string id="30539">从FanArtTV下载额外的art</string>
|
||||||
|
<string id="30540">从FanArtTV下载额外的电影集/收藏art</string>
|
||||||
|
<string id="30541">无需询问挑选特定的串流/质量</string>
|
||||||
|
<string id="30542">总是为预告片挑选最佳质量</string>
|
||||||
|
<string id="30543">Kodi运行在一个低功耗设备上(e.g. 树莓派)</string>
|
||||||
|
<string id="30544">插图</string>
|
||||||
|
<string id="30545">强制图片转码</string>
|
||||||
|
|
||||||
|
<!-- service add-on -->
|
||||||
|
<string id="33000">欢迎</string>
|
||||||
|
<string id="33001">连接错误</string>
|
||||||
|
<string id="33002">服务器不可达</string>
|
||||||
|
<string id="33003">服务器在线</string>
|
||||||
|
<string id="33004">添加到播放列表中的项目</string>
|
||||||
|
<string id="33005">播放列表中的排队项目</string>
|
||||||
|
<string id="33006">服务器正重启</string>
|
||||||
|
<string id="33007">访问已启用</string>
|
||||||
|
<string id="33008">输入用户名的密码</string>
|
||||||
|
<string id="33009">无效的用户名或密码</string>
|
||||||
|
<string id="33010">验证失败次数过多。在设置中重置。</string>
|
||||||
|
<string id="33011">无法直接播放</string>
|
||||||
|
<string id="33012">直接播放失败3此。从HTTP启用播放。</string>
|
||||||
|
<string id="33013">选择音频流</string>
|
||||||
|
<string id="33014">选择字幕流</string>
|
||||||
|
<string id="33015">是否从您的Emby服务器删除文件?</string>
|
||||||
|
<string id="33016">是否播放预告片?</string>
|
||||||
|
<string id="33017">收集电影自:</string>
|
||||||
|
<string id="33018">收集盒装套装</string>
|
||||||
|
<string id="33019">收集音乐视频自</string>
|
||||||
|
<string id="33020">收集电视节目自:</string>
|
||||||
|
<string id="33021">收集</string>
|
||||||
|
<string id="33022">检测到须为此版本的Kodi版Emby重建数据库。是否继续?</string>
|
||||||
|
<string id="33023">数据库重建前Kodi版Emby可能无法正常工作。</string>
|
||||||
|
<string id="33024">正在取消数据库同步进程。当前的Kodi版本不受支持。</string>
|
||||||
|
<string id="33025">预计完成于</string>
|
||||||
|
<string id="33026">比较电影于:</string>
|
||||||
|
<string id="33027">比较盒装套装</string>
|
||||||
|
<string id="33028">比较音乐视频于:</string>
|
||||||
|
<string id="33029">比较电视节目于:</string>
|
||||||
|
<string id="33030">比较剧集于:</string>
|
||||||
|
<string id="33031">比较:</string>
|
||||||
|
<string id="33032">无法生成新的设备ID。查看logs以了解更多信息。</string>
|
||||||
|
<string id="33033">Kodi将立即重启以应用更改。</string>
|
||||||
|
<string id="33041">是否从Plex服务器上删除文件?这也将从磁盘上删除文件!</string>
|
||||||
|
|
||||||
|
<!-- New to Plex -->
|
||||||
|
<string id="39000">- 电影开始前要播放的预告片数量</string>
|
||||||
|
<string id="39001">转码时加强音频</string>
|
||||||
|
<string id="39002">压制字幕尺寸</string>
|
||||||
|
<string id="39003">限制下载同步线程(reg. for rpi: 1)</string>
|
||||||
|
<string id="39004">启用Plex Companion(重启Kodi!)</string>
|
||||||
|
<string id="39005">Plex Companion端口(仅在需要时更改)</string>
|
||||||
|
<string id="39006">激活Plex Companion调试日志</string>
|
||||||
|
<string id="39007">激活Plex Companion GDM调试日志</string>
|
||||||
|
<string id="39008">Plex Companion: 允许通过Plex将媒体扔进Kodi</string>
|
||||||
|
<string id="39009">无法登入plex.tv。请再次尝试登录。</string>
|
||||||
|
<string id="39010">连接到plex.tv遇到问题。网络或互联网有问题?</string>
|
||||||
|
<string id="39011">网络中找不到任何Plex服务器。中止...</string>
|
||||||
|
<string id="39012">选择您的Plex服务器</string>
|
||||||
|
<string id="39013">尚未为Plex服务器授权 </string>
|
||||||
|
<string id="39014">请登录到plex.tv。</string>
|
||||||
|
<string id="39015">登录到服务器时有问题。选择另一台服务器?</string>
|
||||||
|
<string id="39016">停用Plex音乐库?(强烈建议对Plex音乐的大音乐库只使用直接路径。Kodi可能崩溃)</string>
|
||||||
|
<string id="39017">您想现在转入插件设置以调整PKC吗?您将需要重启Kodi!</string>
|
||||||
|
|
||||||
|
<string id="39018">[黄色]修复本地数据库 (强制更新所有内容)[/COLOR]</string>
|
||||||
|
<string id="39019">[红色]部分或全部重置数据库和PKC[/COLOR]</string>
|
||||||
|
<string id="39020">[黄色]现在缓存所有图像到Kodi texture缓存[/COLOR]</string>
|
||||||
|
<string id="39021">[黄色]同步Emby主题媒体到Kodi[/COLOR]</string>
|
||||||
|
<string id="39022">本地</string>
|
||||||
|
<string id="39023">未能通过验证。您没有登录到plex.tv吗?</string>
|
||||||
|
|
||||||
|
|
||||||
|
<string id="39025">启动时自动登录到plex.tv</string>
|
||||||
|
<string id="39026">启用持续背景同步</string>
|
||||||
|
<string id="39027">播放模式</string>
|
||||||
|
<string id="39028">警告!如果选择"Native"模式,你可能无法使用一些Plex特性,如:Plex预告片和转码选项。所有的Plex共享都需要直接路径(e.g. smb://myNAS/mymovie.mkv or \\myNAS/mymovie.mkv)!</string>
|
||||||
|
<string id="39029">网络凭据</string>
|
||||||
|
<string id="39030">添加网络凭据以允许Kodi访问您的内容?注:如果Kodi无法定位您的内容,则跳过此步骤可能会在初始扫描您的内容时生成一个消息。</string>
|
||||||
|
<string id="39031">Kodi无法定位文件: </string>
|
||||||
|
<string id="39032">请验证路径。您可能需要在插件设置中验证您的网络凭据或使用不同的Plex路径。停止同步吗?</string>
|
||||||
|
<string id="39033">自动转换Plex UNC库路径\\myNas\mymovie.mkv为smb路径smb://myNas/mymovie.mkv?(推荐)</string>
|
||||||
|
<string id="39034">用smb://myNas替换Plex UNC路径</string>
|
||||||
|
|
||||||
|
<string id="39035">用自定义SMB路径smb://NAS/mystuff替换Plex路径/volume1/media 或 \\myserver\media</string>
|
||||||
|
<string id="39037">要替换的原始Plex电影路径:</string>
|
||||||
|
<string id="39038">用于替换Plex MOVIE:</string>
|
||||||
|
<string id="39039">要替换的原始Plex TV SHOWS路径:</string>
|
||||||
|
<string id="39040">用于替换Plex TV SHOWS:</string>
|
||||||
|
<string id="39041">要替换的原始Plex MUSIC路径:</string>
|
||||||
|
<string id="39042">用于替换Plex MUSIC:</string>
|
||||||
|
<string id="39043">进一步,完全用自定义SMB路径 (smb://NAS/MyStuff) 替换所有原始Plex库路径
|
||||||
|
(/volume1/media)?</string>
|
||||||
|
<string id="39044">请在设置中"同步选项"下输入自定义smb路径,然后重启Kodi</string>
|
||||||
|
<string id="39045">要替换的原始Plex PHOTO路径:</string>
|
||||||
|
<string id="39046">用于替换Plex PHOTO:</string>
|
||||||
|
<string id="39047">On Deck: 附加显示剧集标题</string>
|
||||||
|
<string id="39048">On Deck: 附加季-和集-数 SxxExx</string>
|
||||||
|
<string id="39049">不起作用?试试完全重置!</string>
|
||||||
|
<string id="39050">[黄色]从列表中选择Plex服务器[/COLOR]</string>
|
||||||
|
<string id="39051">等待同步新的/更改的PMS项目</string>
|
||||||
|
<string id="39052">后台同步</string>
|
||||||
|
<string id="39053">每x分钟进行一次完全库同步</string>
|
||||||
|
<string id="39054">远程</string>
|
||||||
|
<string id="39055">正搜索Plex服务器</string>
|
||||||
|
<string id="39056">用于同步和何时尝试直接播放</string>
|
||||||
|
<string id="39057">自定义路径</string>
|
||||||
|
<string id="39058">扩展Plex TV Series "On Deck"视图到所有节目</string>
|
||||||
|
<string id="39059">最近添加:追加显示剧集标题</string>
|
||||||
|
<string id="39060">最近添加: 追加季-和集-数 SxxExx</string>
|
||||||
|
<string id="39061">您想后台从FanArtTV下载额外的插图吗?</string>
|
||||||
|
<string id="39062">当屏幕保护程序停用时同步</string>
|
||||||
|
<string id="39063">强制转码Hi10P</string>
|
||||||
|
<string id="39064">最近添加:同时显示已观看剧集</string>
|
||||||
|
<string id="39066">最近添加:同时显示已观看电影(Refresh Plex 列表/节点!)</string>
|
||||||
|
<string id="39067">您当前的Plex媒体服务器:</string>
|
||||||
|
<string id="39068">[黄色]手动输入Plex媒体服务器地址[/COLOR]</string>
|
||||||
|
<string id="39069">当前地址:</string>
|
||||||
|
<string id="39070">当前端口:</string>
|
||||||
|
<string id="39071">当前plex.tv状态:</string>
|
||||||
|
<string id="39072">您的Kodi是否安装在一个低功耗设备上如树莓派?如果是,我们将减少Kodi压力以防止崩溃。</string>
|
||||||
|
<string id="39073">外观微调</string>
|
||||||
|
<string id="39074">电视节目</string>
|
||||||
|
<string id="39075">如可能始终使用默认Plex字幕</string>
|
||||||
|
<string id="39076">如果您使用了同一类多个Plex库,e.g. “儿童电影”和”服务电影”,务必查看Wiki: https://goo.gl/JFtQV9</string>
|
||||||
|
<string id="39077">小部件上显示的PMS项目数(e.g. "On Deck")</string>
|
||||||
|
<string id="39078">Plex Companion更新端口(仅在需要时更改)</string>
|
||||||
|
<string id="39079">Plex Companion无法打开GDM端口。请在PKC中更改。</string>
|
||||||
|
|
||||||
|
<!-- Plex Entrypoint.py -->
|
||||||
|
<string id="39200">退出Plex家庭用户 </string>
|
||||||
|
<string id="39201">设置</string>
|
||||||
|
<string id="39202">网络凭据</string>
|
||||||
|
<string id="39203">刷新Plex播放列表/节点</string>
|
||||||
|
<string id="39204">执行手动库同步</string>
|
||||||
|
<string id="39205">无法运行同步,插件未连接到Plex服务器。</string>
|
||||||
|
<string id="39206">多次登录失败后,Plex可能锁定您的帐户。是否继续?</string>
|
||||||
|
<string id="39207">正重置PMS连接,请稍后</string>
|
||||||
|
<string id="39208">重置PKC失败。尝试重启Kodi。</string>
|
||||||
|
<string id="39209">[黄色]切换 plex.tv 登录 (登录或退出)[/COLOR]</string>
|
||||||
|
<string id="39210">尚未连接到Plex服务器</string>
|
||||||
|
<string id="39211">稍后观看</string>
|
||||||
|
<string id="39213">处于离线状态</string>
|
||||||
|
<string id="39214">即使我们登录到 plex.tv,我们无法为PMS授权</string>
|
||||||
|
<string id="39215">输入您的Plex媒体服务器的 IP 或 URL,例如︰</string>
|
||||||
|
|
||||||
|
<string id="39217">你Plex媒体服务器是否支持 SSL 连接?(https而不是 http)?</string>
|
||||||
|
<string id="39218">联系PMS错误</string>
|
||||||
|
<string id="39219">中止 (Yes) 或保存地址 (No)?</string>
|
||||||
|
<string id="39220">已连接</string>
|
||||||
|
<string id="39221">plex.tv 切换成功</string>
|
||||||
|
<string id="39222">[黄色]从FanartTV 寻找缺失的fanart[/COLOR]</string>
|
||||||
|
<string id="39223">仅寻找缺失的fanart或刷新所有fanart?扫描将需要相当一段时间在后台继续。</string>
|
||||||
|
<string id="39224">刷新全部</string>
|
||||||
|
<string id="39225">仅缺失的</string>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Plex Artwork.py -->
|
||||||
|
<string id="39250">运行图像缓存可能会花费一些时间。它将在后台持续。确定要继续吗?</string>
|
||||||
|
<string id="39251">首先重置现有缓存数据吗?</string>
|
||||||
|
|
||||||
|
<!-- Plex PlexAPI.py -->
|
||||||
|
<string id="39300">︰ 输入 plex.tv 的用户名。或不输入来取消。</string>
|
||||||
|
<string id="39301">输入 plex.tv 用户密码 </string>
|
||||||
|
<string id="39302">无法登陆用户 </string>
|
||||||
|
<string id="39303">联系plex.tv时有问题。请稍后再试</string>
|
||||||
|
<string id="39304">转到 https://plex.tv/pin,然后输入代码︰ </string>
|
||||||
|
<string id="39305">无法登录到 plex.tv。请稍后再试</string>
|
||||||
|
<string id="39306">︰ 选择用户</string>
|
||||||
|
<string id="39307">为用户输入 PIN </string>
|
||||||
|
<string id="39308">无法登陆用户 </string>
|
||||||
|
<string id="39309">请重试。</string>
|
||||||
|
<string id="39310">未知</string>
|
||||||
|
<string id="39311">或按No停止登录。</string>
|
||||||
|
|
||||||
|
<!-- Plex Librarysync.py -->
|
||||||
|
<string id="39400">库同步线程崩溃。现在您应重启Kodi。请将此报告至论坛</string>
|
||||||
|
<string id="39401">检测到需要为当前版本重建Kodi数据库。这可能需要一段时间。是否继续?</string>
|
||||||
|
<string id="39402"> 重置数据库前可能无法正常运行。</string>
|
||||||
|
<string id="39403">正在取消数据库同步进程。不支持当前Kodi版本。请验证logs以获取更多信息。</string>
|
||||||
|
<string id="39404">同步进程启动持续失败。尝试重启Kodi中。现在停止同步。</string>
|
||||||
|
<string id="39405">Plex 播放列表/节点已刷新</string>
|
||||||
|
<string id="39406">Plex 播放列表/节点刷新失败</string>
|
||||||
|
<string id="39407">全库同步完成</string>
|
||||||
|
<string id="39408">因无法处理,同步须跳过某些项目。Kodi现在可能不稳定!!请将您的Kodi日志发布到Plex论坛上。</string>
|
||||||
|
<string id="39409">Plex服务器无法一次响应如此多的数据并反回错误。尝试在设置中降低同步下载线程数量。现在已跳过某些项目。</string>
|
||||||
|
<string id="39410">库同步错误</string>
|
||||||
|
|
||||||
|
<!-- Plex videonodes.py -->
|
||||||
|
<string id="39500">On Deck</string>
|
||||||
|
<string id="39501">收藏</string>
|
||||||
|
|
||||||
|
<!-- Plex utils.py -->
|
||||||
|
<string id="39600">确定要重置您的本地Kodi数据库?之后重新同步 Plex 数据需要花费一些时间。</string>
|
||||||
|
<string id="39601">无法停止数据库运行。请稍后再试。</string>
|
||||||
|
<string id="39602">删除所有缓存的插图?(推荐!)</string>
|
||||||
|
<string id="39603">重置所有 PlexKodiConnect Addon 设置?(通常不建议并无必要!)</string>
|
||||||
|
|
||||||
|
<string id="39700">亚马逊 Alexa (语音识别)</string>
|
||||||
|
<string id="39701">激活 Alexa</string>
|
||||||
|
<string id="39702">按文件夹浏览</string>
|
||||||
|
</strings>
|
520
resources/language/Chinese (Traditional)/strings.xml
Normal file
520
resources/language/Chinese (Traditional)/strings.xml
Normal file
|
@ -0,0 +1,520 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||||
|
<strings>
|
||||||
|
<!-- Add-on settings -->
|
||||||
|
<string id="29999">PlexKodiConnect</string>
|
||||||
|
<string id="30000">伺服器位址(IP)</string><!-- Verified -->
|
||||||
|
<string id="30002">喜好的播放方式</string><!-- Verified -->
|
||||||
|
<string id="30004">日誌層級</string><!-- Verified -->
|
||||||
|
<string id="30005">使用者: </string>
|
||||||
|
<string id="30006">密碼 </string>
|
||||||
|
<string id="30007">網路使用者: </string>
|
||||||
|
<string id="30008">網路密碼: </string>
|
||||||
|
<string id="30009">轉碼: </string>
|
||||||
|
<string id="30010">使用性能分析</string>
|
||||||
|
<string id="30011">本地快取系統</string>
|
||||||
|
<string id="30012">確定</string>
|
||||||
|
<string id="30013">不要顯示</string>
|
||||||
|
|
||||||
|
<string id="30014">連結</string>
|
||||||
|
<string id="30015">網路</string>
|
||||||
|
<string id="30016">裝置名稱</string>
|
||||||
|
<string id="30017">Plex伺服器未被授權</string>
|
||||||
|
|
||||||
|
<string id="30022">進階</string>
|
||||||
|
<string id="30024">使用者</string><!-- Verified -->
|
||||||
|
|
||||||
|
<string id="30025">顯示"伺服器未連線"</string>
|
||||||
|
|
||||||
|
<string id="30030">埠號</string><!-- Verified -->
|
||||||
|
<string id="30031">我擁有這個伺服器</string>
|
||||||
|
<string id="30036">顯示幾部新加的電影:</string>
|
||||||
|
<string id="30037">顯示幾部新加的電視節目:</string>
|
||||||
|
<string id="30035">顯示幾張新加的專輯:</string>
|
||||||
|
<string id="30038">開始放就設為「已播放」:</string>
|
||||||
|
<string id="30039">為電視節目設定每季海報</string>
|
||||||
|
|
||||||
|
<string id="30040">依「類型」篩選...</string>
|
||||||
|
<string id="30041">從此全部播放</string>
|
||||||
|
<string id="30042">更新</string>
|
||||||
|
<string id="30043">刪除</string>
|
||||||
|
<string id="30046">將電影加到CouchPotato</string>
|
||||||
|
|
||||||
|
<string id="30044">使用者名稱或密碼錯誤</string>
|
||||||
|
<string id="30045">沒有這個使用者</string>
|
||||||
|
|
||||||
|
<string id="30052">刪除中</string>
|
||||||
|
<string id="30053">等待伺服器刪除中</string>
|
||||||
|
|
||||||
|
<string id="30059">伺服器預設</string>
|
||||||
|
<string id="30060">標題</string>
|
||||||
|
<string id="30061">年</string>
|
||||||
|
<string id="30062">首映日期</string>
|
||||||
|
<string id="30063">創建日期</string>
|
||||||
|
<string id="30064">評論家評分</string>
|
||||||
|
<string id="30065">適群評等</string>
|
||||||
|
<string id="30066">播放次數</string>
|
||||||
|
<string id="30067">預算</string>
|
||||||
|
<!-- Runtime added as 30226 below -->
|
||||||
|
|
||||||
|
<string id="30068">排序</string>
|
||||||
|
|
||||||
|
<string id="30069">無</string>
|
||||||
|
<string id="30070">動作</string>
|
||||||
|
<string id="30071">冒險</string>
|
||||||
|
<string id="30072">動畫</string>
|
||||||
|
<string id="30073">犯罪</string>
|
||||||
|
<string id="30074">喜劇</string>
|
||||||
|
<string id="30075">傳記</string>
|
||||||
|
<string id="30076">戲劇</string>
|
||||||
|
<string id="30077">幻想</string>
|
||||||
|
<string id="30078">外國的</string>
|
||||||
|
<string id="30079">歷史</string>
|
||||||
|
<string id="30080">恐怖</string>
|
||||||
|
<string id="30081">音樂</string>
|
||||||
|
<string id="30082">音樂的</string>
|
||||||
|
<string id="30083">神秘</string>
|
||||||
|
<string id="30084">浪漫</string>
|
||||||
|
<string id="30085">科幻</string>
|
||||||
|
<string id="30086">短劇</string>
|
||||||
|
<string id="30087">懸疑</string>
|
||||||
|
<string id="30088">驚悚</string>
|
||||||
|
<string id="30089">歐美</string>
|
||||||
|
|
||||||
|
<string id="30090">依類別篩選</string>
|
||||||
|
<string id="30091">確認刪除檔案</string><!-- Verified -->
|
||||||
|
<string id="30092">確認要刪除?這會刪除該媒體及所有相關資料檔。</string>
|
||||||
|
|
||||||
|
<string id="30093">設為「已觀看」</string>
|
||||||
|
<string id="30094">設為「未觀看」</string>
|
||||||
|
<string id="30095">加到最愛</string>
|
||||||
|
<string id="30096">移除最愛</string>
|
||||||
|
<string id="30097">依... 排序</string>
|
||||||
|
<string id="30098">降序</string>
|
||||||
|
<string id="30099">升序</string>
|
||||||
|
<string id="30100">顯示人員</string>
|
||||||
|
|
||||||
|
<!-- resume dialog -->
|
||||||
|
<string id="30105">繼續</string>
|
||||||
|
<string id="30106">指定播放位置</string>
|
||||||
|
<string id="30107">從頭播放</string>
|
||||||
|
|
||||||
|
<string id="30110">介面</string>
|
||||||
|
<string id="30111">顯示串流資訊</string>
|
||||||
|
<string id="30112">顯示人員</string>
|
||||||
|
<string id="30113">顯示簡介</string>
|
||||||
|
<string id="30114">播放後刪除</string><!-- Verified -->
|
||||||
|
<string id="30115">針對電視節目</string><!-- Verified -->
|
||||||
|
<string id="30116">針對電影</string><!-- Verified -->
|
||||||
|
<string id="30117">背景更新頻率(秒)</string>
|
||||||
|
<string id="30118">顯示已播放比率</string>
|
||||||
|
<string id="30119">顯示電視節目集數</string>
|
||||||
|
<string id="30120">顯示加載進度</string>
|
||||||
|
<string id="30121">加載內容中</string>
|
||||||
|
<string id="30122">數據檢索中</string>
|
||||||
|
<string id="30125">完成</string>
|
||||||
|
<string id="30126">處理內容: </string>
|
||||||
|
<string id="30128">播放錯誤</string>
|
||||||
|
<string id="30129">內容無法播放</string>
|
||||||
|
<string id="30130">偵測到本地路徑</string>
|
||||||
|
<string id="30131">你的MB3伺服器包含區網位置。請變更伺服器位置到UNC或是將XBMC3C「從串流播放」設定為「True」。路徑: </string>
|
||||||
|
<string id="30132">警告</string>
|
||||||
|
<string id="30133">除錯日誌已啟動</string>
|
||||||
|
<string id="30134">這會影響效能。</string>
|
||||||
|
<string id="30135">錯誤</string>
|
||||||
|
<string id="30136">監控服務未啟動</string>
|
||||||
|
<string id="30137">如果是新安裝的,請重啟Kodi</string>
|
||||||
|
<string id="30138">搜尋</string>
|
||||||
|
|
||||||
|
<string id="30139">啟用劇院音樂(須重啟)</string>
|
||||||
|
<string id="30140"> 循環播放劇院音樂</string>
|
||||||
|
<string id="30141">顯示背景圖片(須重啟)</string>
|
||||||
|
<string id="30142">服務項目</string>
|
||||||
|
|
||||||
|
<string id="30143">執行轉碼,如果傳輸率高於</string>
|
||||||
|
|
||||||
|
<string id="30150">Skin不支援設置視圖</string>
|
||||||
|
<string id="30151">選擇執行動作(須重啟)</string>
|
||||||
|
|
||||||
|
<string id="30156">依標題排序「即將上映」</string>
|
||||||
|
<string id="30157">使用進階圖片(例: CoverArt)</string><!-- Verified -->
|
||||||
|
<string id="30158">元數據</string>
|
||||||
|
<string id="30159">背景海報</string>
|
||||||
|
<string id="30160">轉碼影像品質</string><!-- Verified -->
|
||||||
|
|
||||||
|
<string id="30161">啟用建議的裝載器(須重啟)</string>
|
||||||
|
<string id="30162">顯示季數</string>
|
||||||
|
<string id="30163">不依季分階</string>
|
||||||
|
|
||||||
|
<string id="30164">直播-HTTP</string>
|
||||||
|
<string id="30165">直播</string>
|
||||||
|
<string id="30166">轉碼中</string>
|
||||||
|
<string id="30167">伺服器檢測成功</string>
|
||||||
|
<string id="30168">找到伺服器</string>
|
||||||
|
<string id="30169">位址: </string>
|
||||||
|
|
||||||
|
<!-- Video nodes -->
|
||||||
|
<string id="30170">最近添加的電視節目</string><!-- Verified -->
|
||||||
|
<string id="30171">未看完的電視節目</string><!-- Verified -->
|
||||||
|
<string id="30172">所有的音樂</string>
|
||||||
|
<string id="30173">頻道</string><!-- Verified -->
|
||||||
|
<string id="30174">最近加入</string><!-- Verified -->
|
||||||
|
<string id="30175">最近新增的劇集</string><!-- Verified -->
|
||||||
|
<string id="30176">最近新增的專輯</string>
|
||||||
|
<string id="30177">未看完的電影</string><!-- Verified -->
|
||||||
|
<string id="30178">未看完的劇集</string><!-- Verified -->
|
||||||
|
<string id="30179">下一集</string><!-- Verified -->
|
||||||
|
<string id="30180">最愛的電影</string><!-- Verified -->
|
||||||
|
<string id="30181">最愛的電視節目</string><!-- Verified -->
|
||||||
|
<string id="30182">最愛的劇集</string>
|
||||||
|
<string id="30183">最常播放的專輯</string>
|
||||||
|
<string id="30184">即將上映的影集</string>
|
||||||
|
<string id="30185">合集</string>
|
||||||
|
<string id="30186">預告片</string>
|
||||||
|
<string id="30187">音樂視頻</string>
|
||||||
|
<string id="30188">相片</string>
|
||||||
|
<string id="30189">未觀看的電影</string><!-- Verified -->
|
||||||
|
<string id="30190">電影分類</string>
|
||||||
|
<string id="30191">製片廠</string>
|
||||||
|
<string id="30192">電影演員</string>
|
||||||
|
<string id="30193">未觀看的劇集</string>
|
||||||
|
<string id="30194">電視節目分類</string>
|
||||||
|
<string id="30195">電視網路</string>
|
||||||
|
<string id="30196">電視演員</string>
|
||||||
|
<string id="30197">播放列表</string>
|
||||||
|
<string id="30198">搜尋</string>
|
||||||
|
<string id="30199">設置的視圖</string>
|
||||||
|
|
||||||
|
<string id="30200">選擇使用者</string>
|
||||||
|
<string id="30201">啟用分析。</string>
|
||||||
|
<string id="30202">請記得完成測試後關閉。</string>
|
||||||
|
<string id="30203">旋轉背景海報線程發生錯誤</string>
|
||||||
|
<string id="30204">無法連線到伺服器</string>
|
||||||
|
<string id="30205">載入選單選項的線程發生錯誤</string>
|
||||||
|
|
||||||
|
<string id="30206">啟用播放清單載入程式 (須重啟)</string>
|
||||||
|
|
||||||
|
<string id="30207">歌曲</string>
|
||||||
|
<string id="30208">專輯</string>
|
||||||
|
<string id="30209">專輯演唱者</string>
|
||||||
|
<string id="30210">演出者</string>
|
||||||
|
<string id="30211">音樂流派</string>
|
||||||
|
|
||||||
|
<string id="30212">啟用主題視頻 (須重啟)</string>
|
||||||
|
<string id="30213"> -循環播放主題視頻</string>
|
||||||
|
|
||||||
|
<string id="30216">同一季自動播放下一集</string>
|
||||||
|
<string id="30218">壓縮背景海報</string>
|
||||||
|
<string id="30220">最新的 </string>
|
||||||
|
<string id="30221">進行中 </string>
|
||||||
|
<string id="30222">即將上映 </string>
|
||||||
|
<string id="30223">使用者視圖</string>
|
||||||
|
<string id="30224">報告度量</string>
|
||||||
|
<string id="30225">使用Kodi排序</string>
|
||||||
|
<string id="30226">運行時間</string>
|
||||||
|
|
||||||
|
<string id="30227">隨機</string>
|
||||||
|
<string id="30228">最近發佈的</string>
|
||||||
|
<string id="30229">隨機播放項目</string><!-- Verified -->
|
||||||
|
<string id="30230">推薦</string><!-- Verified -->
|
||||||
|
|
||||||
|
<string id="30235">附加功能</string><!-- Verified -->
|
||||||
|
<string id="30236">同步主題音樂</string>
|
||||||
|
<string id="30237">同步額外的背景海報</string>
|
||||||
|
<string id="30238">同步電影合集</string>
|
||||||
|
|
||||||
|
<string id="30239">[COLOR yellow]重置 Kodi 資料庫[/COLOR]</string><!-- Verified -->
|
||||||
|
<string id="30240">啟用「已看過」及「觀看進度」同步</string>
|
||||||
|
<string id="30241">資料庫同步進度:</string>
|
||||||
|
<string id="30242">播放次數同步進度:</string>
|
||||||
|
<string id="30243">啟用 HTTPS</string><!-- Verified -->
|
||||||
|
<string id="30245">強迫使用特定的轉碼</string>
|
||||||
|
|
||||||
|
<string id="30246">使用 Netflix 的「即將上映」通知樣式</string>
|
||||||
|
<string id="30247"> 設定通知即將播放完畢前的秒數</string>
|
||||||
|
<string id="30248">在 播放/選擇 時顯示 Plex 資訊對話方塊</string>
|
||||||
|
<string id="30249">啟動時顯示連接伺服器的訊息</string><!-- Verified -->
|
||||||
|
|
||||||
|
<string id="30251">最近添加的家庭視頻</string><!-- Verified -->
|
||||||
|
<string id="30252">最近添加的照片</string><!-- Verified -->
|
||||||
|
<string id="30253">最愛的家庭視頻</string><!-- Verified -->
|
||||||
|
<string id="30254">最愛的照片</string><!-- Verified -->
|
||||||
|
<string id="30255">最喜歡的專輯</string>
|
||||||
|
|
||||||
|
<string id="30256">最近添加的音樂視頻</string><!-- Verified -->
|
||||||
|
<string id="30257">進行中的音樂視頻</string><!-- Verified -->
|
||||||
|
<string id="30258">未觀看的音樂視頻</string><!-- Verified -->
|
||||||
|
|
||||||
|
<!-- Default views -->
|
||||||
|
<string id="30300">啟動中</string>
|
||||||
|
<string id="30301">清除設置</string>
|
||||||
|
<string id="30302">電影</string>
|
||||||
|
<string id="30303">合集</string>
|
||||||
|
<string id="30304">預告片</string>
|
||||||
|
<string id="30305">系列</string>
|
||||||
|
<string id="30306">季</string>
|
||||||
|
<string id="30307">劇集</string>
|
||||||
|
<string id="30308">音樂演出者</string>
|
||||||
|
<string id="30309">音樂專輯</string>
|
||||||
|
<string id="30310">音樂視頻</string>
|
||||||
|
<string id="30311">音樂曲目</string>
|
||||||
|
<string id="30312">頻道</string>
|
||||||
|
|
||||||
|
<!-- contextmenu -->
|
||||||
|
<string id="30401">Plex 選項</string>
|
||||||
|
<string id="30402">清除類似的項目</string>
|
||||||
|
<string id="30403">喜歡的項目</string>
|
||||||
|
<string id="30404">不喜歡的項目</string>
|
||||||
|
<string id="30405">加到 Plex 的最愛</string>
|
||||||
|
<string id="30406">從 Plex 的最愛移除</string>
|
||||||
|
<string id="30407">設置自訂歌曲評級</string>
|
||||||
|
<string id="30408">Plex 外掛程式設置</string>
|
||||||
|
<string id="30409">從伺服器中刪除項目</string>
|
||||||
|
<string id="30410">刷新這項</string>
|
||||||
|
<string id="30411">設置自訂歌曲評級 (0-5)</string>
|
||||||
|
<string id="30412">強迫轉碼</string>
|
||||||
|
<string id="30413">啟用在Kodi顯示Plex內容功能表</string>
|
||||||
|
<string id="30414">無法刪除Plex的項目, 請確認Plex伺服器是否已啟動刪除功能?</string>
|
||||||
|
<string id="30415">從 Plex 伺服器播放</string>
|
||||||
|
<string id="30416">Plex 伺服器的設置</string>
|
||||||
|
|
||||||
|
<!-- add-on settings -->
|
||||||
|
<string id="30500">驗證主機 SSL 憑證 (更安全)</string>
|
||||||
|
<string id="30501">用戶端 SSL 憑證</string>
|
||||||
|
<string id="30502">使用備用位址</string>
|
||||||
|
<string id="30503">待命伺服器位址</string>
|
||||||
|
<string id="30504">使用備用設備名稱</string>
|
||||||
|
<string id="30505">[COLOR yellow]重置嘗試登入次數[/COLOR]</string>
|
||||||
|
<string id="30506">同步處理選項</string>
|
||||||
|
<string id="30507">顯示同步進度</string>
|
||||||
|
<string id="30508">同步空的電視影集</string>
|
||||||
|
<string id="30509">啟動音樂資料庫</string>
|
||||||
|
<string id="30510">直接串流音樂資料庫</string>
|
||||||
|
<string id="30511">播放模式</string>
|
||||||
|
<string id="30512">強制暫存背景海報</string>
|
||||||
|
<string id="30513">限制快取背景海報(對Raspberry Pi是必須的)</string>
|
||||||
|
<string id="30514">啟用快速啟動 (需要伺服器外掛程式)</string>
|
||||||
|
<string id="30515">同時對伺服器請求最多的項目數</string>
|
||||||
|
<string id="30516">播放</string>
|
||||||
|
<string id="30517">[COLOR yellow]輸入網路通行訊息[/COLOR]</string>
|
||||||
|
<string id="30518">啟用Plex 預告片(需Plexpass資格)</string>
|
||||||
|
<string id="30519">播放預告片</string>
|
||||||
|
<string id="30520">跳過Plex確認刪除內容的警訊(自己承擔誤刪的風險)</string>
|
||||||
|
<string id="30521">恢復播放時跳回秒數</string>
|
||||||
|
<string id="30522">播放 h265/HEVC 時,強迫轉碼</string>
|
||||||
|
<string id="30523">音樂中繼資料選項 (與直接串流不相容)</string>
|
||||||
|
<string id="30524">直接從檔中導入音樂歌曲評級</string>
|
||||||
|
<string id="30525">將音樂歌曲評級轉換為 Plex 評級</string>
|
||||||
|
<string id="30526">允許更新歌曲檔評級</string>
|
||||||
|
<string id="30527">播放下一集時忽略特輯</string>
|
||||||
|
<string id="30528">多使用者永久加入會話(session)</string>
|
||||||
|
<string id="30529">啟動延遲 (以秒為單位)</string>
|
||||||
|
<string id="30530">啟用「伺服器重新開機」訊息</string>
|
||||||
|
<string id="30531">啟用「新的內容」通知</string>
|
||||||
|
<string id="30532">彈出視訊資料庫持續時間 (以秒為單位)</string>
|
||||||
|
<string id="30533">彈出的音樂資料庫持續時間 (以秒為單位)</string>
|
||||||
|
<string id="30534">伺服器訊息:</string>
|
||||||
|
<string id="30535">[COLOR yellow]產生新的唯一的設備辨識碼(例如:當複製Kodi時)[/COLOR]</string>
|
||||||
|
<string id="30536">重開Kodi時使用者必須重新登入</string>
|
||||||
|
<string id="30537">如果您進行任何更改,請重開Kodi</string>
|
||||||
|
<string id="30538">必需完全重新同步</string>
|
||||||
|
<string id="30539">從 FanArtTV 下載額外的背景海報</string>
|
||||||
|
<string id="30540">從 FanArtTV 下載電影合輯海報</string>
|
||||||
|
<string id="30541">不要要求挑選特定的 串流/品質</string>
|
||||||
|
<string id="30542">總是挑選最好品質的預告片</string>
|
||||||
|
<string id="30543">Kodi在低效能的設備上運行 (例如Raspberry Pi)</string>
|
||||||
|
<string id="30544">背景海報</string>
|
||||||
|
<string id="30545">強制圖片轉碼</string>
|
||||||
|
|
||||||
|
<!-- service add-on -->
|
||||||
|
<string id="33000">歡迎</string>
|
||||||
|
<string id="33001">連線錯誤</string>
|
||||||
|
<string id="33002">無法連到伺服器</string>
|
||||||
|
<string id="33003">伺服器已上線</string>
|
||||||
|
<string id="33004">加入播放清單</string>
|
||||||
|
<string id="33005">排入播放清單</string>
|
||||||
|
<string id="33006">伺服器正在重新開機</string>
|
||||||
|
<string id="33007">可以使用</string>
|
||||||
|
<string id="33008">輸入使用者的密碼︰</string>
|
||||||
|
<string id="33009">無效的使用者名稱或密碼</string>
|
||||||
|
<string id="33010">未能通過身份驗證的次數太多。請重置設定。</string>
|
||||||
|
<string id="33011">不能直接播放</string>
|
||||||
|
<string id="33012">直接播放失敗了 3 次。使用 HTTP 的播放。</string>
|
||||||
|
<string id="33013">選擇音軌</string>
|
||||||
|
<string id="33014">選擇字幕</string>
|
||||||
|
<string id="33015">從你的 Plex 伺服器上刪除檔?</string>
|
||||||
|
<string id="33016">播放預告嗎?</string>
|
||||||
|
<string id="33017">取得電影︰</string>
|
||||||
|
<string id="33018">取得合輯</string>
|
||||||
|
<string id="33019">取得音樂視頻︰</string>
|
||||||
|
<string id="33020">取得電視節目︰</string>
|
||||||
|
<string id="33021">取得中:</string>
|
||||||
|
<string id="33022">偵測到使用本版PKC需要為Kodi重新創建資料庫 。是否繼續?</string>
|
||||||
|
<string id="33023">直到完成重置資料庫,PKC可能無法正常工作。</string>
|
||||||
|
<string id="33024">取消資料庫同步過程。當前的Kodi版本不受支援 (請勿使用mySQL)。</string>
|
||||||
|
<string id="33025">已完成:</string>
|
||||||
|
<string id="33026">比對電影:</string>
|
||||||
|
<string id="33027">比對電影合集:</string>
|
||||||
|
<string id="33028">比對音樂視頻︰</string>
|
||||||
|
<string id="33029">比對電視節目︰</string>
|
||||||
|
<string id="33030">比對電視節目集︰</string>
|
||||||
|
<string id="33031">比對︰</string>
|
||||||
|
<string id="33032">無法產生新的設備辨識碼。查看日誌以瞭解更多資訊。</string>
|
||||||
|
<string id="33033">Kodi將立即重啟,以套用更改的設定。</string>
|
||||||
|
<string id="33041">從Plex上刪除檔案?這也將會從磁碟中刪除檔案 !</string>
|
||||||
|
|
||||||
|
<!-- New to Plex -->
|
||||||
|
<string id="39000">- 預先播放預告片的數量</string>
|
||||||
|
<string id="39001">轉碼時提高音訊</string>
|
||||||
|
<string id="39002">設定字幕大小</string>
|
||||||
|
<string id="39003">限制下載同步執行緒 (rpi 的建議︰ 1)</string>
|
||||||
|
<string id="39004">啟用Plex Companion (重啟Kodi!)</string>
|
||||||
|
<string id="39005">Plex Companion 埠 (僅在需要時更改)</string>
|
||||||
|
<string id="39006">啟用Plex Companion 除錯日誌</string>
|
||||||
|
<string id="39007">啟用Plex Companion GDM 除錯日誌</string>
|
||||||
|
<string id="39008">Plex Companion︰允許透過Plex傳送媒體到Kodi</string>
|
||||||
|
<string id="39009">無法登錄到 plex.tv。請重試。</string>
|
||||||
|
<string id="39010">連接到 plex.tv 有問題。網路或互聯網的問題?</string>
|
||||||
|
<string id="39011">在網路中找不到任何Plex伺服器。中止...</string>
|
||||||
|
<string id="39012">選擇您的Plex伺服器</string>
|
||||||
|
<string id="39013">尚未為Plex伺服器授權 </string>
|
||||||
|
<string id="39014">請登錄到 plex.tv。</string>
|
||||||
|
<string id="39015">連接到伺服器時出現問題。選擇另一台伺服器?</string>
|
||||||
|
<string id="39016">禁用Plex音樂資料庫嗎?(強烈建議使用Plex音樂時,大型音樂資料庫只用直接路徑。否則Kodi可能會崩潰)</string>
|
||||||
|
<string id="39017">你現在想轉到外掛程式的設置進行微調 PKC 嗎?您將需要重新開啟Kodi !</string>
|
||||||
|
|
||||||
|
<string id="39018">[COLOR yellow]修復本機資料庫 (強制更新所有內容)[/COLOR]</string>
|
||||||
|
<string id="39019">[COLOR red]部分或全部重置資料庫和PKC[/COLOR]</string>
|
||||||
|
<string id="39020">[COLOR yellow]立即暫存所有圖像到Kodi的圖像暫存區[/COLOR]</string>
|
||||||
|
<string id="39021">[COLOR yellow]同步 Plex 主題媒體到 Kodi[/COLOR]</string>
|
||||||
|
<string id="39022">本地</string>
|
||||||
|
<string id="39023">未能通過身份驗證。您沒有登錄到 plex.tv 嗎?</string>
|
||||||
|
|
||||||
|
|
||||||
|
<string id="39025">在啟動時自動登入到 plex.tv</string>
|
||||||
|
<string id="39026">啟用持續的背景同步</string>
|
||||||
|
<string id="39027">播放模式</string>
|
||||||
|
<string id="39028">小心 !如果您選擇"原生"模式下,你可能會失去某些Plex功能,例如:預告片和轉碼選項。所有Plex 的分享,都需要使用直接的路徑(如 smb://myNAS/mymovie.mkv 或 \\myNAS/mymovie.mkv)!</string>
|
||||||
|
<string id="39029">網路憑據</string>
|
||||||
|
<string id="39030">添加網路憑據,以允許科迪訪問您的內容嗎?注︰ 跳過這一步在初始掃描期間可能產生一個訊息,如果Kodi找不到您的內容。</string>
|
||||||
|
<string id="39031">科迪找不到檔案︰ </string>
|
||||||
|
<string id="39032">請驗證路徑。您可能需要驗證您的載入項設置中的網路憑據或使用不同的Plex路徑。停止同步嗎?</string>
|
||||||
|
<string id="39033">自動變換Plex資料庫 UNC 路徑 \\myNas\mymovie.mkv 到 smb 路徑,smb://myNas/mymovie.mkv? (推薦)</string>
|
||||||
|
<string id="39034">用 smb://myNas 替換 Plex UNC 路徑 \\myNas</string>
|
||||||
|
|
||||||
|
<string id="39035">用自訂 SMB 路徑 smb://NAS/mystuff 替換內定路徑 /volume1/media 或 \\myserver\media</string>
|
||||||
|
<string id="39037">要替換的Plex原始電影路徑︰</string>
|
||||||
|
<string id="39038">取代Plex電影路徑,用︰</string>
|
||||||
|
<string id="39039">要被替換的 Plex 原始電視路徑︰</string>
|
||||||
|
<string id="39040">替換Plex電視路徑為︰</string>
|
||||||
|
<string id="39041">要替換的Plex原始音樂路徑︰</string>
|
||||||
|
<string id="39042">取代Plex音樂路徑,用︰</string>
|
||||||
|
<string id="39043">進一步完全用自訂SMB路徑(smb://NAS/MyStuff) 替換所有Plex資料庫路徑(/volume1/媒體) 嗎?</string>
|
||||||
|
<string id="39044">請輸入您自訂的smb路徑在"設置"下的"同步選項",然後重新開啟kodi</string>
|
||||||
|
<string id="39045">要替換的Plex原始照片路徑︰</string>
|
||||||
|
<string id="39046">取代Plex照片路徑,用︰</string>
|
||||||
|
<string id="39047">上架︰增加顯示電視節目集標題</string>
|
||||||
|
<string id="39048">上架︰增加季和集編號 SxxExx</string>
|
||||||
|
<string id="39049">完全掛點?試著完全重置 !</string>
|
||||||
|
<string id="39050">[COLOR yellow]選取Plex伺服器[/COLOR]</string>
|
||||||
|
<string id="39051">在同步前等待"新增或修改" Plex媒體伺服器項目完成</string>
|
||||||
|
<string id="39052">背景同步</string>
|
||||||
|
<string id="39053">每隔 x 分鐘做完整資料庫同步</string>
|
||||||
|
<string id="39054">遠端</string>
|
||||||
|
<string id="39055">正在搜索Plex伺服器</string>
|
||||||
|
<string id="39056">通過同步和嘗試使用直接播放</string>
|
||||||
|
<string id="39057">自訂路徑</string>
|
||||||
|
<string id="39058">延伸plex電視節目系列"上架"視圖,到所有節目</string>
|
||||||
|
<string id="39059">最近添加︰ 追加顯示集標題</string>
|
||||||
|
<string id="39060">最近添加︰ 追加和本集編號 SxxExx</string>
|
||||||
|
<string id="39061">你想在背景從 FanArtTV 下載更多圖片?</string>
|
||||||
|
<string id="39062">停用螢幕保護程式時同步</string>
|
||||||
|
<string id="39063">播放Hi10P 時強迫轉碼</string>
|
||||||
|
<string id="39064">最近添加︰ 一併顯示已觀看</string>
|
||||||
|
<string id="39066">最近添加︰ 也顯示已觀看的電影 (刷新Plex播放清單/節點!)</string>
|
||||||
|
<string id="39067">您當前的Plex媒體伺服器︰</string>
|
||||||
|
<string id="39068">[COLOR yellow]手動輸入Plex媒體伺服器位址[/COLOR]</string>
|
||||||
|
<string id="39069">當前位址︰</string>
|
||||||
|
<string id="39070">當前埠︰</string>
|
||||||
|
<string id="39071">plex.tv 狀態︰</string>
|
||||||
|
<string id="39072">Kodi裝在像Raspbery Pi的低效能設備上嗎?如果是的話,我們會減少Kodi的壓力,防止它崩潰。</string>
|
||||||
|
<string id="39073">外觀微調</string>
|
||||||
|
<string id="39074">電視節目</string>
|
||||||
|
<string id="39075">如果可能的話,使用預設 Plex 字幕</string>
|
||||||
|
<string id="39076">如果您使用多個同類的Plex資料庫,例如"兒童電影"和"父母電影",務必參閱 Wiki: HTTPs://goo.gl/JFtQV9</string>
|
||||||
|
<string id="39077">PMS 中顯示在小工具集 (例如"上架") 品項的數目</string>
|
||||||
|
<string id="39078">Plex Companion更新埠(僅在需要時更改)</string>
|
||||||
|
<string id="39079">Plex Companion不能打開 GDM 埠。請在 PKC 設置中更改它。</string>
|
||||||
|
|
||||||
|
<!-- Plex Entrypoint.py -->
|
||||||
|
<string id="39200">登出Plex Home用戶 </string>
|
||||||
|
<string id="39201">設置</string>
|
||||||
|
<string id="39202">網路憑據</string>
|
||||||
|
<string id="39203">刷新Plex的播放清單/節點</string>
|
||||||
|
<string id="39204">手動執行資料庫同步</string>
|
||||||
|
<string id="39205">外掛未連接到Plex伺服器,同步無法執行.</string>
|
||||||
|
<string id="39206">如果你多次登錄失敗,Plex可能會鎖定您的帳戶。是否繼續?</string>
|
||||||
|
<string id="39207">重置 PMS 連線,請稍候</string>
|
||||||
|
<string id="39208">重置 PKC 失敗。嘗試重開Kodi。</string>
|
||||||
|
<string id="39209">[COLOR yellow]切換 plex.tv 帳號 (登錄或登出) [/COLOR]</string>
|
||||||
|
<string id="39210">尚未連接到Plex伺服器</string>
|
||||||
|
<string id="39211">稍後再看</string>
|
||||||
|
<string id="39213">處於離線狀態</string>
|
||||||
|
<string id="39214">雖然我們登錄到 plex.tv,我們無法為PMS取得授權</string>
|
||||||
|
<string id="39215">輸入您的Plex媒體伺服器的 IP 或 URL,例子︰</string>
|
||||||
|
|
||||||
|
<string id="39217">你的Plex媒體伺服器是否支援 SSL 連線?(HTTPS 而不是 HTTP) ?</string>
|
||||||
|
<string id="39218">PMS連線錯誤</string>
|
||||||
|
<string id="39219">中止 (Yes) 或保存位址 (No)?</string>
|
||||||
|
<string id="39220">已連結</string>
|
||||||
|
<string id="39221">plex.tv 切換成功</string>
|
||||||
|
<string id="39222">[COLOR yellow]由FanartTV搜尋漏失的背景海報中[/COLOR]</string>
|
||||||
|
<string id="39223">只搜尋缺失的背景海報或刷新所有的背景海報嗎?掃描在背景執行,將需要相當一段時間。</string>
|
||||||
|
<string id="39224">刷新所有</string>
|
||||||
|
<string id="39225">僅缺失部分</string>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Plex Artwork.py -->
|
||||||
|
<string id="39250">在背景執行圖像暫存,可能需要花費一些時間。確實要繼續嗎?</string>
|
||||||
|
<string id="39251">優先重置暫存中的現有資料?</string>
|
||||||
|
|
||||||
|
<!-- Plex PlexAPI.py -->
|
||||||
|
<string id="39300">︰輸入 plex.tv 的使用者。或留空取消。</string>
|
||||||
|
<string id="39301">輸入 plex.tv 使用者密碼 </string>
|
||||||
|
<string id="39302">無法登入使用者 </string>
|
||||||
|
<string id="39303">連接 plex.tv 有問題。請稍後再試</string>
|
||||||
|
<string id="39304">請到 https://plex.tv/pin,然後輸入代碼︰ </string>
|
||||||
|
<string id="39305">無法登錄到 plex.tv。請稍後再試</string>
|
||||||
|
<string id="39306">︰ 選擇使用者</string>
|
||||||
|
<string id="39307">為使用者輸入 PIN </string>
|
||||||
|
<string id="39308">使用者無法登錄 </string>
|
||||||
|
<string id="39309">請重試。</string>
|
||||||
|
<string id="39310">未知</string>
|
||||||
|
<string id="39311">或按No,不登入。</string>
|
||||||
|
|
||||||
|
<!-- Plex Librarysync.py -->
|
||||||
|
<string id="39400">資料庫同步執行緒已經掛點。立刻重啟Kodi。請將此狀況回報論壇</string>
|
||||||
|
<string id="39401">檢測到此版本需要重新創建Kodi資料庫。這可能需要一段時間。是否繼續?</string>
|
||||||
|
<string id="39402"> 可能無法正常運行,直到重置資料庫。</string>
|
||||||
|
<string id="39403">取消資料庫同步程序。不支援當前kodi版本。請檢查您的日誌了解更多的資訊。</string>
|
||||||
|
<string id="39404">啟動同步程序一再失敗。請嘗試重開Kodi。停止同步。</string>
|
||||||
|
<string id="39405">已刷新Plex 播放清單/節點</string>
|
||||||
|
<string id="39406">Plex 播放清單/節點刷新失敗</string>
|
||||||
|
<string id="39407">所有資料庫同步完成</string>
|
||||||
|
<string id="39408">同步不得不跳過某些項目,因為他們無法處理。Kodi現在可能不穩定!! 請張貼kodi日誌到Plex論壇。</string>
|
||||||
|
<string id="39409">Plex伺服器不喜歡你一次要求這麼多資料,並返回錯誤。嘗試降低同步下載中的執行緒數設置。跳過一些項目。</string>
|
||||||
|
<string id="39410">資料庫同步錯誤</string>
|
||||||
|
|
||||||
|
<!-- Plex videonodes.py -->
|
||||||
|
<string id="39500">上架</string>
|
||||||
|
<string id="39501">收藏</string>
|
||||||
|
|
||||||
|
<!-- Plex utils.py -->
|
||||||
|
<string id="39600">你確定你想要重置您的本地Kodi資料庫?再重新同步 Plex 資料需要時間。</string>
|
||||||
|
<string id="39601">不能停止運行中的資料庫。請稍後再試。</string>
|
||||||
|
<string id="39602">刪除所有暫存的背景海報嗎?(推薦!)</string>
|
||||||
|
<string id="39603">重置所有的 PlexKodiConnect 外掛程式設置嗎?(不推薦且沒有必要!)</string>
|
||||||
|
|
||||||
|
<string id="39700">亞馬遜 Alexa (語音辨識)</string>
|
||||||
|
<string id="39701">啟動 Alexa</string>
|
||||||
|
<string id="39702">依資料夾流覽</string>
|
||||||
|
</strings>
|
|
@ -1,520 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
|
||||||
<strings>
|
|
||||||
<!-- Add-on settings -->
|
|
||||||
<string id="29999">PlexKodiConnect</string>
|
|
||||||
<string id="30000">Server Address (IP)</string><!-- Verified -->
|
|
||||||
<string id="30002">Preferred playback method</string><!-- Verified -->
|
|
||||||
<string id="30004">Log level</string><!-- Verified -->
|
|
||||||
<string id="30005">Username: </string>
|
|
||||||
<string id="30006">Password: </string>
|
|
||||||
<string id="30007">Network Username: </string>
|
|
||||||
<string id="30008">Network Password: </string>
|
|
||||||
<string id="30009">Transcode: </string>
|
|
||||||
<string id="30010">Enable Performance Profiling</string>
|
|
||||||
<string id="30011">Local caching system</string>
|
|
||||||
<string id="30012">OK</string>
|
|
||||||
<string id="30013">Never show</string>
|
|
||||||
|
|
||||||
<string id="30014">Connection</string>
|
|
||||||
<string id="30015">Network</string>
|
|
||||||
<string id="30016">Device Name</string>
|
|
||||||
<string id="30017">Unauthorized for PMS</string>
|
|
||||||
|
|
||||||
<string id="30022">Advanced</string>
|
|
||||||
<string id="30024">Username</string><!-- Verified -->
|
|
||||||
|
|
||||||
<string id="30025">Display message if PMS goes offline</string>
|
|
||||||
|
|
||||||
<string id="30030">Port Number</string><!-- Verified -->
|
|
||||||
<string id="30031">I own this Plex Media Server</string>
|
|
||||||
<string id="30036">Number of recent Movies to show:</string>
|
|
||||||
<string id="30037">Number of recent TV episodes to show:</string>
|
|
||||||
<string id="30035">Number of recent Music Albums to show:</string>
|
|
||||||
<string id="30038">Mark watched at start of playback:</string>
|
|
||||||
<string id="30039">Set Season poster for episodes</string>
|
|
||||||
|
|
||||||
<string id="30040">Genre Filter ...</string>
|
|
||||||
<string id="30041">Play All from Here</string>
|
|
||||||
<string id="30042">Refresh</string>
|
|
||||||
<string id="30043">Delete</string>
|
|
||||||
<string id="30046">Add Movie to CouchPotato</string>
|
|
||||||
|
|
||||||
<string id="30044">Incorrect Username/Password</string>
|
|
||||||
<string id="30045">Username not found</string>
|
|
||||||
|
|
||||||
<string id="30052">Deleting</string>
|
|
||||||
<string id="30053">Waiting for server to delete</string>
|
|
||||||
|
|
||||||
<string id="30059">Server Default</string>
|
|
||||||
<string id="30060">Title</string>
|
|
||||||
<string id="30061">Year</string>
|
|
||||||
<string id="30062">Premiere Date</string>
|
|
||||||
<string id="30063">Date Created</string>
|
|
||||||
<string id="30064">Critic Rating</string>
|
|
||||||
<string id="30065">Community Rating</string>
|
|
||||||
<string id="30066">Play Count</string>
|
|
||||||
<string id="30067">Budget</string>
|
|
||||||
<!-- Runtime added as 30226 below -->
|
|
||||||
|
|
||||||
<string id="30068">Sort By</string>
|
|
||||||
|
|
||||||
<string id="30069">None</string>
|
|
||||||
<string id="30070">Action</string>
|
|
||||||
<string id="30071">Adventure</string>
|
|
||||||
<string id="30072">Animation</string>
|
|
||||||
<string id="30073">Crime</string>
|
|
||||||
<string id="30074">Comedy</string>
|
|
||||||
<string id="30075">Documentary</string>
|
|
||||||
<string id="30076">Drama</string>
|
|
||||||
<string id="30077">Fantasy</string>
|
|
||||||
<string id="30078">Foreign</string>
|
|
||||||
<string id="30079">History</string>
|
|
||||||
<string id="30080">Horror</string>
|
|
||||||
<string id="30081">Music</string>
|
|
||||||
<string id="30082">Musical</string>
|
|
||||||
<string id="30083">Mystery</string>
|
|
||||||
<string id="30084">Romance</string>
|
|
||||||
<string id="30085">Science Fiction</string>
|
|
||||||
<string id="30086">Short</string>
|
|
||||||
<string id="30087">Suspense</string>
|
|
||||||
<string id="30088">Thriller</string>
|
|
||||||
<string id="30089">Western</string>
|
|
||||||
|
|
||||||
<string id="30090">Genre Filter</string>
|
|
||||||
<string id="30091">Confirm file deletion</string><!-- Verified -->
|
|
||||||
<string id="30092">Delete this item? This action will delete media and associated data files.</string>
|
|
||||||
|
|
||||||
<string id="30093">Mark Watched</string>
|
|
||||||
<string id="30094">Mark Unwatched</string>
|
|
||||||
<string id="30095">Add to Favorites</string>
|
|
||||||
<string id="30096">Remove from Favorites</string>
|
|
||||||
<string id="30097">Sort By ...</string>
|
|
||||||
<string id="30098">Sort Order Descending</string>
|
|
||||||
<string id="30099">Sort Order Ascending</string>
|
|
||||||
<string id="30100">Show People</string>
|
|
||||||
|
|
||||||
<!-- resume dialog -->
|
|
||||||
<string id="30105">Resume</string>
|
|
||||||
<string id="30106">Resume from</string>
|
|
||||||
<string id="30107">Start from beginning</string>
|
|
||||||
|
|
||||||
<string id="30110">Interface</string>
|
|
||||||
<string id="30111">Include Stream Info</string>
|
|
||||||
<string id="30112">Include People</string>
|
|
||||||
<string id="30113">Include Overview</string>
|
|
||||||
<string id="30114">Offer delete after playback</string><!-- Verified -->
|
|
||||||
<string id="30115">For Episodes</string><!-- Verified -->
|
|
||||||
<string id="30116">For Movies</string><!-- Verified -->
|
|
||||||
<string id="30117">Background Art Refresh Rate (seconds)</string>
|
|
||||||
<string id="30118">Add Resume Percent</string>
|
|
||||||
<string id="30119">Add Episode Number</string>
|
|
||||||
<string id="30120">Show Load Progress</string>
|
|
||||||
<string id="30121">Loading Content</string>
|
|
||||||
<string id="30122">Retrieving Data</string>
|
|
||||||
<string id="30125">Done</string>
|
|
||||||
<string id="30126">Processing Item : </string>
|
|
||||||
<string id="30128">Play Error</string>
|
|
||||||
<string id="30129">This item is not playable</string>
|
|
||||||
<string id="30130">Local path detected</string>
|
|
||||||
<string id="30131">Your MB3 Server contains local paths. Please change server paths to UNC or change XBMB3C setting 'Play from Stream' to true. Path: </string>
|
|
||||||
<string id="30132">Warning</string>
|
|
||||||
<string id="30133">Debug logging enabled.</string>
|
|
||||||
<string id="30134">This will affect performance.</string>
|
|
||||||
<string id="30135">Error</string>
|
|
||||||
<string id="30136">Monitoring service is not running</string>
|
|
||||||
<string id="30137">If you have just installed please restart Kodi</string>
|
|
||||||
<string id="30138">Search</string>
|
|
||||||
|
|
||||||
<string id="30139">Enable Theme Music (Requires Restart)</string>
|
|
||||||
<string id="30140"> - Loop Theme Music</string>
|
|
||||||
<string id="30141">Enable Background Image (Requires Restart)</string>
|
|
||||||
<string id="30142">Services</string>
|
|
||||||
|
|
||||||
<string id="30143">Always transcode if video bitrate is above</string>
|
|
||||||
|
|
||||||
<string id="30150">Skin does not support setting views</string>
|
|
||||||
<string id="30151">Select item action (Requires Restart)</string>
|
|
||||||
|
|
||||||
<string id="30156">Sort NextUp by Show Title</string>
|
|
||||||
<string id="30157">Enable Enhanced Images (eg CoverArt)</string><!-- Verified -->
|
|
||||||
<string id="30158">Metadata</string>
|
|
||||||
<string id="30159">Artwork</string>
|
|
||||||
<string id="30160">Video Quality if Transcoding necessary</string><!-- Verified -->
|
|
||||||
|
|
||||||
<string id="30161">Enable Suggested Loader (Requires Restart)</string>
|
|
||||||
<string id="30162">Add Season Number</string>
|
|
||||||
<string id="30163">Flatten Seasons</string>
|
|
||||||
|
|
||||||
<string id="30164">Direct Play - HTTP</string>
|
|
||||||
<string id="30165">Direct Play</string>
|
|
||||||
<string id="30166">Transcoding</string>
|
|
||||||
<string id="30167">Server Detection Succeeded</string>
|
|
||||||
<string id="30168">Found server</string>
|
|
||||||
<string id="30169">Address : </string>
|
|
||||||
|
|
||||||
<!-- Video nodes -->
|
|
||||||
<string id="30170">Recently Added TV Shows</string><!-- Verified -->
|
|
||||||
<string id="30171">In Progress TV Shows</string><!-- Verified -->
|
|
||||||
<string id="30172">All Music</string>
|
|
||||||
<string id="30173">Channels</string><!-- Verified -->
|
|
||||||
<string id="30174">Recently Added</string><!-- Verified -->
|
|
||||||
<string id="30175">Recently Added Episodes</string><!-- Verified -->
|
|
||||||
<string id="30176">Recently Added Albums</string>
|
|
||||||
<string id="30177">In Progress Movies</string><!-- Verified -->
|
|
||||||
<string id="30178">In Progress Episodes</string><!-- Verified -->
|
|
||||||
<string id="30179">Next Episodes</string><!-- Verified -->
|
|
||||||
<string id="30180">Favorite Movies</string><!-- Verified -->
|
|
||||||
<string id="30181">Favorite Shows</string><!-- Verified -->
|
|
||||||
<string id="30182">Favorite Episodes</string>
|
|
||||||
<string id="30183">Frequent Played Albums</string>
|
|
||||||
<string id="30184">Upcoming TV</string>
|
|
||||||
<string id="30185">BoxSets</string>
|
|
||||||
<string id="30186">Trailers</string>
|
|
||||||
<string id="30187">Music Videos</string>
|
|
||||||
<string id="30188">Photos</string>
|
|
||||||
<string id="30189">Unwatched Movies</string><!-- Verified -->
|
|
||||||
<string id="30190">Movie Genres</string>
|
|
||||||
<string id="30191">Movie Studios</string>
|
|
||||||
<string id="30192">Movie Actors</string>
|
|
||||||
<string id="30193">Unwatched Episodes</string>
|
|
||||||
<string id="30194">TV Genres</string>
|
|
||||||
<string id="30195">TV Networks</string>
|
|
||||||
<string id="30196">TV Actors</string>
|
|
||||||
<string id="30197">Playlists</string>
|
|
||||||
<string id="30198">Search</string>
|
|
||||||
<string id="30199">Set Views</string>
|
|
||||||
|
|
||||||
<string id="30200">Select User</string>
|
|
||||||
<string id="30201">Profiling enabled.</string>
|
|
||||||
<string id="30202">Please remember to turn off when finished testing.</string>
|
|
||||||
<string id="30203">Error in ArtworkRotationThread</string>
|
|
||||||
<string id="30204">Unable to connect to server</string>
|
|
||||||
<string id="30205">Error in LoadMenuOptionsThread</string>
|
|
||||||
|
|
||||||
<string id="30206">Enable Playlists Loader (Requires Restart)</string>
|
|
||||||
|
|
||||||
<string id="30207">Songs</string>
|
|
||||||
<string id="30208">Albums</string>
|
|
||||||
<string id="30209">Album Artists</string>
|
|
||||||
<string id="30210">Artists</string>
|
|
||||||
<string id="30211">Music Genres</string>
|
|
||||||
|
|
||||||
<string id="30212">Enable Theme Videos (Requires Restart)</string>
|
|
||||||
<string id="30213"> - Loop Theme Videos</string>
|
|
||||||
|
|
||||||
<string id="30216">AutoPlay remaining episodes in a season</string>
|
|
||||||
<string id="30218">Compress Artwork</string>
|
|
||||||
<string id="30220">Latest </string>
|
|
||||||
<string id="30221">In Progress </string>
|
|
||||||
<string id="30222">NextUp </string>
|
|
||||||
<string id="30223">User Views</string>
|
|
||||||
<string id="30224">Report Metrics</string>
|
|
||||||
<string id="30225">Use Kodi Sorting</string>
|
|
||||||
<string id="30226">Runtime</string>
|
|
||||||
|
|
||||||
<string id="30227">Random</string>
|
|
||||||
<string id="30228">Recently releases</string>
|
|
||||||
<string id="30229">Random Items</string><!-- Verified -->
|
|
||||||
<string id="30230">Recommended</string><!-- Verified -->
|
|
||||||
|
|
||||||
<string id="30235">Extras</string><!-- Verified -->
|
|
||||||
<string id="30236">Sync Theme Music</string>
|
|
||||||
<string id="30237">Sync Extra Fanart</string>
|
|
||||||
<string id="30238">Sync Movie BoxSets</string>
|
|
||||||
|
|
||||||
<string id="30239">[COLOR yellow]Reset local Kodi database[/COLOR]</string><!-- Verified -->
|
|
||||||
<string id="30240">Enable watched/resume status sync</string>
|
|
||||||
<string id="30241">DB Sync Indication:</string>
|
|
||||||
<string id="30242">Play Count Sync Indication:</string>
|
|
||||||
<string id="30243">Enable HTTPS</string><!-- Verified -->
|
|
||||||
<string id="30245">Force Transcoding Codecs</string>
|
|
||||||
|
|
||||||
<string id="30246">Enable Netflix style next up notification</string>
|
|
||||||
<string id="30247"> - The number of seconds before the end to show the notification</string>
|
|
||||||
<string id="30248">Show Emby Info dialog on play/select action</string>
|
|
||||||
<string id="30249">Enable server connection message on startup</string><!-- Verified -->
|
|
||||||
|
|
||||||
<string id="30251">Recently added Home Videos</string><!-- Verified -->
|
|
||||||
<string id="30252">Recently added Photos</string><!-- Verified -->
|
|
||||||
<string id="30253">Favorite Home Videos</string><!-- Verified -->
|
|
||||||
<string id="30254">Favorite Photos</string><!-- Verified -->
|
|
||||||
<string id="30255">Favorite Albums</string>
|
|
||||||
|
|
||||||
<string id="30256">Recently added Music videos</string><!-- Verified -->
|
|
||||||
<string id="30257">In progress Music videos</string><!-- Verified -->
|
|
||||||
<string id="30258">Unwatched Music videos</string><!-- Verified -->
|
|
||||||
|
|
||||||
<!-- Default views -->
|
|
||||||
<string id="30300">Active</string>
|
|
||||||
<string id="30301">Clear Settings</string>
|
|
||||||
<string id="30302">Movies</string>
|
|
||||||
<string id="30303">BoxSets</string>
|
|
||||||
<string id="30304">Trailers</string>
|
|
||||||
<string id="30305">Series</string>
|
|
||||||
<string id="30306">Seasons</string>
|
|
||||||
<string id="30307">Episodes</string>
|
|
||||||
<string id="30308">Music Artists</string>
|
|
||||||
<string id="30309">Music Albums</string>
|
|
||||||
<string id="30310">Music Videos</string>
|
|
||||||
<string id="30311">Music Tracks</string>
|
|
||||||
<string id="30312">Channels</string>
|
|
||||||
|
|
||||||
<!-- contextmenu -->
|
|
||||||
<string id="30401">Plex options</string>
|
|
||||||
<string id="30402">Clear like for this item</string>
|
|
||||||
<string id="30403">Like this item</string>
|
|
||||||
<string id="30404">Dislike this item</string>
|
|
||||||
<string id="30405">Add to Plex favorites</string>
|
|
||||||
<string id="30406">Remove from Plex favorites</string>
|
|
||||||
<string id="30407">Set custom song rating</string>
|
|
||||||
<string id="30408">Plex addon settings</string>
|
|
||||||
<string id="30409">Delete item from server</string>
|
|
||||||
<string id="30410">Refresh this item</string>
|
|
||||||
<string id="30411">Set custom song rating (0-5)</string>
|
|
||||||
<string id="30412">Force transcode</string>
|
|
||||||
<string id="30413">Enable Plex context menu in Kodi</string>
|
|
||||||
<string id="30414">Could not delete the Plex item. Is item deletion enabled on the Plex Media Server?</string>
|
|
||||||
<string id="30415">Start playback via PMS</string>
|
|
||||||
<string id="30416">Settings for the Plex Server</string>
|
|
||||||
|
|
||||||
<!-- add-on settings -->
|
|
||||||
<string id="30500">Verify Host SSL Certificate (more secure)</string>
|
|
||||||
<string id="30501">Client SSL certificate</string>
|
|
||||||
<string id="30502">Use alternate address</string>
|
|
||||||
<string id="30503">Alternate Server Address</string>
|
|
||||||
<string id="30504">Use alternate device Name</string>
|
|
||||||
<string id="30505">[COLOR yellow]Reset login attempts[/COLOR]</string>
|
|
||||||
<string id="30506">Sync Options</string>
|
|
||||||
<string id="30507">Show syncing progress</string>
|
|
||||||
<string id="30508">Sync empty TV Shows</string>
|
|
||||||
<string id="30509">Enable Music Library</string>
|
|
||||||
<string id="30510">Direct stream music library</string>
|
|
||||||
<string id="30511">Playback Mode</string>
|
|
||||||
<string id="30512">Force artwork caching</string>
|
|
||||||
<string id="30513">Limit artwork cache threads (recommended for rpi)</string>
|
|
||||||
<string id="30514">Enable fast startup (requires server plugin)</string>
|
|
||||||
<string id="30515">Maximum items to request from the server at once</string>
|
|
||||||
<string id="30516">Playback</string>
|
|
||||||
<string id="30517">[COLOR yellow]Enter network credentials[/COLOR]</string>
|
|
||||||
<string id="30518">Enable Plex Trailers (Plexpass is needed)</string>
|
|
||||||
<string id="30519">Ask to play trailers</string>
|
|
||||||
<string id="30520">Skip Plex delete confirmation for the context menu (use at your own risk)</string>
|
|
||||||
<string id="30521">Jump back on resume (in seconds)</string>
|
|
||||||
<string id="30522">Force transcode h265/HEVC</string>
|
|
||||||
<string id="30523">Music metadata options (not compatible with direct stream)</string>
|
|
||||||
<string id="30524">Import music song rating directly from files</string>
|
|
||||||
<string id="30525">Convert music song rating to Emby rating</string>
|
|
||||||
<string id="30526">Allow rating in song files to be updated</string>
|
|
||||||
<string id="30527">Ignore specials in next episodes</string>
|
|
||||||
<string id="30528">Permanent users to add to the session</string>
|
|
||||||
<string id="30529">Startup delay (in seconds)</string>
|
|
||||||
<string id="30530">Enable server restart message</string>
|
|
||||||
<string id="30531">Enable new content notification</string>
|
|
||||||
<string id="30532">Duration of the video library pop up (in seconds)</string>
|
|
||||||
<string id="30533">Duration of the music library pop up (in seconds)</string>
|
|
||||||
<string id="30534">Server messages</string>
|
|
||||||
<string id="30535">[COLOR yellow]Generate a new unique device Id (e.g. when cloning Kodi)[/COLOR]</string>
|
|
||||||
<string id="30536">Users must log in every time Kodi restarts</string>
|
|
||||||
<string id="30537">RESTART KODI IF YOU MAKE ANY CHANGES</string>
|
|
||||||
<string id="30538">Complete Re-Sync necessary</string>
|
|
||||||
<string id="30539">Download additional art from FanArtTV</string>
|
|
||||||
<string id="30540">Download movie set/collection art from FanArtTV</string>
|
|
||||||
<string id="30541">Don't ask to pick a certain stream/quality</string>
|
|
||||||
<string id="30542">Always pick best quality for trailers</string>
|
|
||||||
<string id="30543">Kodi runs on a low-power device (e.g. Raspberry Pi)</string>
|
|
||||||
<string id="30544">Artwork</string>
|
|
||||||
<string id="30545">Force transcode pictures</string>
|
|
||||||
|
|
||||||
<!-- service add-on -->
|
|
||||||
<string id="33000">Welcome</string>
|
|
||||||
<string id="33001">Error connecting</string>
|
|
||||||
<string id="33002">Server is unreachable</string>
|
|
||||||
<string id="33003">Server is online</string>
|
|
||||||
<string id="33004">items added to playlist</string>
|
|
||||||
<string id="33005">items queued to playlist</string>
|
|
||||||
<string id="33006">Server is restarting</string>
|
|
||||||
<string id="33007">Access is enabled</string>
|
|
||||||
<string id="33008">Enter password for user:</string>
|
|
||||||
<string id="33009">Invalid username or password</string>
|
|
||||||
<string id="33010">Failed to authenticate too many times. Reset in the settings.</string>
|
|
||||||
<string id="33011">Unable to direct play</string>
|
|
||||||
<string id="33012">Direct play failed 3 times. Enabled play from HTTP.</string>
|
|
||||||
<string id="33013">Choose the audio stream</string>
|
|
||||||
<string id="33014">Choose the subtitles stream</string>
|
|
||||||
<string id="33015">Delete file from your Emby server?</string>
|
|
||||||
<string id="33016">Play trailers?</string>
|
|
||||||
<string id="33017">Gathering movies from:</string>
|
|
||||||
<string id="33018">Gathering boxsets</string>
|
|
||||||
<string id="33019">Gathering music videos from:</string>
|
|
||||||
<string id="33020">Gathering tv shows from:</string>
|
|
||||||
<string id="33021">Gathering:</string>
|
|
||||||
<string id="33022">Detected the database needs to be recreated for this version of Emby for Kodi. Proceed?</string>
|
|
||||||
<string id="33023">Emby for Kodi may not work correctly until the database is reset.</string>
|
|
||||||
<string id="33024">Cancelling the database syncing process. The current Kodi version is unsupported.</string>
|
|
||||||
<string id="33025">completed in:</string>
|
|
||||||
<string id="33026">Comparing movies from:</string>
|
|
||||||
<string id="33027">Comparing boxsets</string>
|
|
||||||
<string id="33028">Comparing music videos from:</string>
|
|
||||||
<string id="33029">Comparing tv shows from:</string>
|
|
||||||
<string id="33030">Comparing episodes from:</string>
|
|
||||||
<string id="33031">Comparing:</string>
|
|
||||||
<string id="33032">Failed to generate a new device Id. See your logs for more information.</string>
|
|
||||||
<string id="33033">Kodi will now restart to apply the changes.</string>
|
|
||||||
<string id="33041">Delete file(s) from Plex Server? This will also delete the file(s) from disk!</string>
|
|
||||||
|
|
||||||
<!-- New to Plex -->
|
|
||||||
<string id="39000">- Number of trailers to play before a movie</string>
|
|
||||||
<string id="39001">Boost audio when transcoding</string>
|
|
||||||
<string id="39002">Burnt-in subtitle size</string>
|
|
||||||
<string id="39003">Limit download sync threads (rec. for rpi: 1)</string>
|
|
||||||
<string id="39004">Enable Plex Companion (restart Kodi!)</string>
|
|
||||||
<string id="39005">Plex Companion Port (change only if needed)</string>
|
|
||||||
<string id="39006">Activate Plex Companion debug log</string>
|
|
||||||
<string id="39007">Activate Plex Companion GDM debug log</string>
|
|
||||||
<string id="39008">Plex Companion: Allows flinging media to Kodi through Plex</string>
|
|
||||||
<string id="39009">Could not login to plex.tv. Please try signing in again.</string>
|
|
||||||
<string id="39010">Problems connecting to plex.tv. Network or internet issue?</string>
|
|
||||||
<string id="39011">Could not find any Plex server in the network. Aborting...</string>
|
|
||||||
<string id="39012">Choose your Plex server</string>
|
|
||||||
<string id="39013">Not yet authorized for Plex server </string>
|
|
||||||
<string id="39014">Please sign in to plex.tv.</string>
|
|
||||||
<string id="39015">Problems connecting to server. Pick another server?</string>
|
|
||||||
<string id="39016">Disable Plex music library? (It is HIGHLY recommended to use Plex music only with direct paths for large music libraries. Kodi might crash otherwise)</string>
|
|
||||||
<string id="39017">Would you now like to go to the plugin's settings to fine-tune PKC? You will need to RESTART Kodi!</string>
|
|
||||||
|
|
||||||
<string id="39018">[COLOR yellow]Repair local database (force update all content)[/COLOR]</string>
|
|
||||||
<string id="39019">[COLOR red]Partial or full reset of Database and PKC[/COLOR]</string>
|
|
||||||
<string id="39020">[COLOR yellow]Cache all images to Kodi texture cache now[/COLOR]</string>
|
|
||||||
<string id="39021">[COLOR yellow]Sync Emby Theme Media to Kodi[/COLOR]</string>
|
|
||||||
<string id="39022">local</string>
|
|
||||||
<string id="39023">Failed to authenticate. Did you login to plex.tv?</string>
|
|
||||||
|
|
||||||
|
|
||||||
<string id="39025">Automatically log into plex.tv on startup</string>
|
|
||||||
<string id="39026">Enable constant background sync</string>
|
|
||||||
<string id="39027">Playback Mode</string>
|
|
||||||
<string id="39028">CAUTION! If you choose "Native" mode , you might loose access to certain Plex features such as: Plex trailers and transcoding options. ALL Plex shares need to use direct paths (e.g. smb://myNAS/mymovie.mkv or \\myNAS/mymovie.mkv)!</string>
|
|
||||||
<string id="39029">Network credentials</string>
|
|
||||||
<string id="39030">Add network credentials to allow Kodi access to your content? Note: Skipping this step may generate a message during the initial scan of your content if Kodi can't locate your content.</string>
|
|
||||||
<string id="39031">Kodi can't locate file: </string>
|
|
||||||
<string id="39032">Please verify the path. You may need to verify your network credentials in the add-on settings or use different Plex paths. Stop syncing?</string>
|
|
||||||
<string id="39033">Transform Plex UNC library paths \\myNas\mymovie.mkv automatically to smb paths, smb://myNas/mymovie.mkv? (recommended)</string>
|
|
||||||
<string id="39034">Replace Plex UNC paths \\myNas with smb://myNas</string>
|
|
||||||
|
|
||||||
<string id="39035">Replace Plex paths /volume1/media or \\myserver\media with custom SMB paths smb://NAS/mystuff</string>
|
|
||||||
<string id="39037">Original Plex MOVIE path to replace:</string>
|
|
||||||
<string id="39038">Replace Plex MOVIE with:</string>
|
|
||||||
<string id="39039">Original Plex TV SHOWS path to replace:</string>
|
|
||||||
<string id="39040">Replace Plex TV SHOWS with:</string>
|
|
||||||
<string id="39041">Original Plex MUSIC path to replace:</string>
|
|
||||||
<string id="39042">Replace Plex MUSIC with:</string>
|
|
||||||
<string id="39043">Go a step further and completely replace all original Plex library paths (/volume1/media) with custom SMB paths (smb://NAS/MyStuff)?</string>
|
|
||||||
<string id="39044">Please enter your custom smb paths in the settings under "Sync Options" and then restart Kodi</string>
|
|
||||||
<string id="39045">Original Plex PHOTO path to replace:</string>
|
|
||||||
<string id="39046">Replace Plex PHOTO with:</string>
|
|
||||||
<string id="39047">On Deck: Append show title to episode</string>
|
|
||||||
<string id="39048">On Deck: Append season- and episode-number SxxExx</string>
|
|
||||||
<string id="39049">Nothing works? Try a full reset!</string>
|
|
||||||
<string id="39050">[COLOR yellow]Choose Plex Server from a list[/COLOR]</string>
|
|
||||||
<string id="39051">Wait before sync new/changed PMS item [s]</string>
|
|
||||||
<string id="39052">Background Sync</string>
|
|
||||||
<string id="39053">Do a full library sync every x minutes</string>
|
|
||||||
<string id="39054">remote</string>
|
|
||||||
<string id="39055">Searching for Plex Server</string>
|
|
||||||
<string id="39056">Used by Sync and when attempting to Direct Play</string>
|
|
||||||
<string id="39057">Customize Paths</string>
|
|
||||||
<string id="39058">Extend Plex TV Series "On Deck" view to all shows</string>
|
|
||||||
<string id="39059">Recently Added: Append show title to episode</string>
|
|
||||||
<string id="39060">Recently Added: Append season- and episode-number SxxExx</string>
|
|
||||||
<string id="39061">Would you like to download additional artwork from FanArtTV in the background?</string>
|
|
||||||
<string id="39062">Sync when screensaver is deactivated</string>
|
|
||||||
<string id="39063">Force Transcode Hi10P</string>
|
|
||||||
<string id="39064">Recently Added: Also show already watched episodes</string>
|
|
||||||
<string id="39066">Recently Added: Also show already watched movies (Refresh Plex playlist/nodes!)</string>
|
|
||||||
<string id="39067">Your current Plex Media Server:</string>
|
|
||||||
<string id="39068">[COLOR yellow]Manually enter Plex Media Server address[/COLOR]</string>
|
|
||||||
<string id="39069">Current address:</string>
|
|
||||||
<string id="39070">Current port:</string>
|
|
||||||
<string id="39071">Current plex.tv status:</string>
|
|
||||||
<string id="39072">Is your Kodi installed on a low-powered device like a Raspberry Pi? If yes, then we will reduce the strain on Kodi to prevent it from crashing.</string>
|
|
||||||
<string id="39073">Appearance Tweaks</string>
|
|
||||||
<string id="39074">TV Shows</string>
|
|
||||||
<string id="39075">Always use default Plex subtitle if possible</string>
|
|
||||||
<string id="39076">If you use several Plex libraries of one kind, e.g. "Kids Movies" and "Parents Movies", be sure to check the Wiki: https://goo.gl/JFtQV9</string>
|
|
||||||
<string id="39077">Number of PMS items to show in widgets (e.g. "On Deck")</string>
|
|
||||||
<string id="39078">Plex Companion Update Port (change only if needed)</string>
|
|
||||||
<string id="39079">Plex Companion could not open the GDM port. Please change it in the PKC settings.</string>
|
|
||||||
|
|
||||||
<!-- Plex Entrypoint.py -->
|
|
||||||
<string id="39200">Log-out Plex Home User </string>
|
|
||||||
<string id="39201">Settings</string>
|
|
||||||
<string id="39202">Network credentials</string>
|
|
||||||
<string id="39203">Refresh Plex playlists/nodes</string>
|
|
||||||
<string id="39204">Perform manual library sync</string>
|
|
||||||
<string id="39205">Unable to run the sync, the add-on is not connected to a Plex server.</string>
|
|
||||||
<string id="39206">Plex might lock your account if you fail to log in too many times. Proceed anyway?</string>
|
|
||||||
<string id="39207">Resetting PMS connections, please wait</string>
|
|
||||||
<string id="39208">Failed to reset PKC. Try to restart Kodi.</string>
|
|
||||||
<string id="39209">[COLOR yellow]Toggle plex.tv login (sign in or sign out)[/COLOR]</string>
|
|
||||||
<string id="39210">Not yet connected to Plex Server</string>
|
|
||||||
<string id="39211">Watch later</string>
|
|
||||||
<string id="39213">is offline</string>
|
|
||||||
<string id="39214">Even though we signed in to plex.tv, we could not authorize for PMS</string>
|
|
||||||
<string id="39215">Enter your Plex Media Server's IP or URL, Examples are:</string>
|
|
||||||
|
|
||||||
<string id="39217">Does your Plex Media Server support SSL connections? (https instead of http)?</string>
|
|
||||||
<string id="39218">Error contacting PMS</string>
|
|
||||||
<string id="39219">Abort (Yes) or save address anyway (No)?</string>
|
|
||||||
<string id="39220">connected</string>
|
|
||||||
<string id="39221">plex.tv toggle successful</string>
|
|
||||||
<string id="39222">[COLOR yellow]Look for missing fanart on FanartTV now[/COLOR]</string>
|
|
||||||
<string id="39223">Only look for missing fanart or refresh all fanart? The scan will take quite a while and happen in the background.</string>
|
|
||||||
<string id="39224">Refresh all</string>
|
|
||||||
<string id="39225">Missing only</string>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Plex Artwork.py -->
|
|
||||||
<string id="39250">Running the image cache process can take some time. It will happen in the background. Are you sure you want continue?</string>
|
|
||||||
<string id="39251">Reset all existing cache data first?</string>
|
|
||||||
|
|
||||||
<!-- Plex PlexAPI.py -->
|
|
||||||
<string id="39300">: Enter plex.tv username. Or nothing to cancel.</string>
|
|
||||||
<string id="39301">Enter password for plex.tv user </string>
|
|
||||||
<string id="39302">Could not sign in user </string>
|
|
||||||
<string id="39303">Problems trying to contact plex.tv. Try again later</string>
|
|
||||||
<string id="39304">Go to https://plex.tv/pin and enter the code: </string>
|
|
||||||
<string id="39305">Could not sign in to plex.tv. Try again later</string>
|
|
||||||
<string id="39306">: Select User</string>
|
|
||||||
<string id="39307">Enter PIN for user </string>
|
|
||||||
<string id="39308">Could not log in user </string>
|
|
||||||
<string id="39309">Please try again.</string>
|
|
||||||
<string id="39310">unknown</string>
|
|
||||||
<string id="39311">or press No to not sign in.</string>
|
|
||||||
|
|
||||||
<!-- Plex Librarysync.py -->
|
|
||||||
<string id="39400">Library sync thread has crashed. You should restart Kodi now. Please report this on the forum</string>
|
|
||||||
<string id="39401">Detected Kodi database needs to be recreated for this version. This might take a while. Proceed?</string>
|
|
||||||
<string id="39402"> may not work correctly until the database is reset.</string>
|
|
||||||
<string id="39403">Cancelling the database syncing process. Current Kodi version is unsupported. Please verify your logs for more info.</string>
|
|
||||||
<string id="39404">Startup syncing process failed repeatedly. Try restarting Kodi. Stopping Sync for now.</string>
|
|
||||||
<string id="39405">Plex playlists/nodes refreshed</string>
|
|
||||||
<string id="39406">Plex playlists/nodes refresh failed</string>
|
|
||||||
<string id="39407">Full library sync finished</string>
|
|
||||||
<string id="39408">Sync had to skip some items because they could not be processed. Kodi may be instable now!! Please post your Kodi logs to the Plex forum.</string>
|
|
||||||
<string id="39409">The Plex Server did not like you asking for so much data at once and returned ERRORS. Try lowering the number of sync download threads in the settings. Skipped some items for now.</string>
|
|
||||||
<string id="39410">ERROR in library sync</string>
|
|
||||||
|
|
||||||
<!-- Plex videonodes.py -->
|
|
||||||
<string id="39500">On Deck</string>
|
|
||||||
<string id="39501">Collections</string>
|
|
||||||
|
|
||||||
<!-- Plex utils.py -->
|
|
||||||
<string id="39600">Are you sure you want to reset your local Kodi database? A re-sync of the Plex data will take time afterwards.</string>
|
|
||||||
<string id="39601">Could not stop the database from running. Please try again later.</string>
|
|
||||||
<string id="39602">Remove all cached artwork? (recommended!)</string>
|
|
||||||
<string id="39603">Reset all PlexKodiConnect Addon settings? (this is usually NOT recommended and unnecessary!)</string>
|
|
||||||
|
|
||||||
<string id="39700">Amazon Alexa (Voice Recognition)</string>
|
|
||||||
<string id="39701">Activate Alexa</string>
|
|
||||||
<string id="39702">Browse by folder</string>
|
|
||||||
</strings>
|
|
1871
resources/language/resource.language.en_gb/strings.po
Normal file
1871
resources/language/resource.language.en_gb/strings.po
Normal file
File diff suppressed because it is too large
Load diff
|
@ -126,40 +126,6 @@ def double_urldecode(text):
|
||||||
return unquote(unquote(text))
|
return unquote(unquote(text))
|
||||||
|
|
||||||
|
|
||||||
def get_uncached_artwork():
|
|
||||||
"""
|
|
||||||
Returns a list of URLs that haven't been cached yet
|
|
||||||
"""
|
|
||||||
all_urls = []
|
|
||||||
cached_urls = []
|
|
||||||
result = []
|
|
||||||
connection = kodiSQL('video')
|
|
||||||
cursor = connection.cursor()
|
|
||||||
# Get all artwork urls
|
|
||||||
cursor.execute("SELECT url FROM art WHERE media_type != 'actor'")
|
|
||||||
for url in cursor.fetchall():
|
|
||||||
all_urls.append(url[0])
|
|
||||||
connection.close()
|
|
||||||
connection = kodiSQL('music')
|
|
||||||
cursor = connection.cursor()
|
|
||||||
cursor.execute("SELECT url FROM art")
|
|
||||||
for url in cursor.fetchall():
|
|
||||||
all_urls.append(url[0])
|
|
||||||
connection.close()
|
|
||||||
# Get the cached urls
|
|
||||||
connection = kodiSQL('texture')
|
|
||||||
cursor = connection.cursor()
|
|
||||||
cursor.execute("SELECT url FROM texture")
|
|
||||||
for url in cursor.fetchall():
|
|
||||||
cached_urls.append(url[0])
|
|
||||||
connection.close()
|
|
||||||
for url in all_urls:
|
|
||||||
if url not in cached_urls:
|
|
||||||
result.append(url)
|
|
||||||
log.info('%s artwork urls have not been cached yet' % len(result))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@ThreadMethodsAdditionalStop('plex_shouldStop')
|
@ThreadMethodsAdditionalStop('plex_shouldStop')
|
||||||
@ThreadMethods
|
@ThreadMethods
|
||||||
class Image_Cache_Thread(Thread):
|
class Image_Cache_Thread(Thread):
|
||||||
|
|
|
@ -15,14 +15,13 @@ from utils import window, settings, getUnixTimestamp, sourcesXML,\
|
||||||
ThreadMethods, ThreadMethodsAdditionalStop, LogTime, getScreensaver,\
|
ThreadMethods, ThreadMethodsAdditionalStop, LogTime, getScreensaver,\
|
||||||
setScreensaver, playlistXSP, language as lang, DateToKodi, reset,\
|
setScreensaver, playlistXSP, language as lang, DateToKodi, reset,\
|
||||||
advancedSettingsXML, tryDecode, deletePlaylists, deleteNodes, \
|
advancedSettingsXML, tryDecode, deletePlaylists, deleteNodes, \
|
||||||
ThreadMethodsAdditionalSuspend, create_actor_db_index, tryEncode, dialog
|
ThreadMethodsAdditionalSuspend, create_actor_db_index, dialog
|
||||||
import downloadutils
|
import downloadutils
|
||||||
import itemtypes
|
import itemtypes
|
||||||
import plexdb_functions as plexdb
|
import plexdb_functions as plexdb
|
||||||
import kodidb_functions as kodidb
|
import kodidb_functions as kodidb
|
||||||
import userclient
|
import userclient
|
||||||
import videonodes
|
import videonodes
|
||||||
import artwork
|
|
||||||
import variables as v
|
import variables as v
|
||||||
|
|
||||||
from PlexFunctions import GetPlexMetadata, GetAllPlexLeaves, scrobble, \
|
from PlexFunctions import GetPlexMetadata, GetAllPlexLeaves, scrobble, \
|
||||||
|
@ -1522,11 +1521,6 @@ class LibrarySync(Thread):
|
||||||
# Initialize time offset Kodi - PMS
|
# Initialize time offset Kodi - PMS
|
||||||
self.syncPMStime()
|
self.syncPMStime()
|
||||||
lastSync = getUnixTimestamp()
|
lastSync = getUnixTimestamp()
|
||||||
if settings('enableTextureCache') == "true":
|
|
||||||
# Start caching artwork that has not been cached yet
|
|
||||||
for url in artwork.get_uncached_artwork():
|
|
||||||
artwork.ARTWORK_QUEUE.put(
|
|
||||||
artwork.double_urlencode(tryEncode((url))))
|
|
||||||
if settings('FanartTV') == 'true':
|
if settings('FanartTV') == 'true':
|
||||||
# Start getting additional missing artwork
|
# Start getting additional missing artwork
|
||||||
with plexdb.Get_Plex_DB() as plex_db:
|
with plexdb.Get_Plex_DB() as plex_db:
|
||||||
|
@ -1612,6 +1606,7 @@ class LibrarySync(Thread):
|
||||||
elif window('plex_runLibScan') == 'del_textures':
|
elif window('plex_runLibScan') == 'del_textures':
|
||||||
window('plex_runLibScan', clear=True)
|
window('plex_runLibScan', clear=True)
|
||||||
window('plex_dbScan', value="true")
|
window('plex_dbScan', value="true")
|
||||||
|
import artwork
|
||||||
artwork.Artwork().fullTextureCacheSync()
|
artwork.Artwork().fullTextureCacheSync()
|
||||||
window('plex_dbScan', clear=True)
|
window('plex_dbScan', clear=True)
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -1,289 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
#################################################################################################
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
|
|
||||||
import xbmc
|
|
||||||
import xbmcaddon
|
|
||||||
import xbmcvfs
|
|
||||||
|
|
||||||
from mutagen.flac import FLAC, Picture
|
|
||||||
from mutagen.id3 import ID3
|
|
||||||
from mutagen import id3
|
|
||||||
import base64
|
|
||||||
|
|
||||||
import read_embyserver as embyserver
|
|
||||||
from utils import window
|
|
||||||
|
|
||||||
#################################################################################################
|
|
||||||
|
|
||||||
log = logging.getLogger("EMBY."+__name__)
|
|
||||||
|
|
||||||
#################################################################################################
|
|
||||||
|
|
||||||
# Helper for the music library, intended to fix missing song ID3 tags on Emby
|
|
||||||
|
|
||||||
def getRealFileName(filename, isTemp=False):
|
|
||||||
#get the filename path accessible by python if possible...
|
|
||||||
|
|
||||||
if not xbmcvfs.exists(filename):
|
|
||||||
log.warn("File does not exist! %s" % filename)
|
|
||||||
return (False, "")
|
|
||||||
|
|
||||||
#if we use os.path method on older python versions (sunch as some android builds), we need to pass arguments as string
|
|
||||||
if os.path.supports_unicode_filenames:
|
|
||||||
checkfile = filename
|
|
||||||
else:
|
|
||||||
checkfile = filename.encode("utf-8")
|
|
||||||
|
|
||||||
# determine if our python module is able to access the file directly...
|
|
||||||
if os.path.exists(checkfile):
|
|
||||||
filename = filename
|
|
||||||
elif os.path.exists(checkfile.replace("smb://","\\\\").replace("/","\\")):
|
|
||||||
filename = filename.replace("smb://","\\\\").replace("/","\\")
|
|
||||||
else:
|
|
||||||
#file can not be accessed by python directly, we copy it for processing...
|
|
||||||
isTemp = True
|
|
||||||
if "/" in filename: filepart = filename.split("/")[-1]
|
|
||||||
else: filepart = filename.split("\\")[-1]
|
|
||||||
tempfile = "special://temp/"+filepart
|
|
||||||
xbmcvfs.copy(filename, tempfile)
|
|
||||||
filename = xbmc.translatePath(tempfile).decode("utf-8")
|
|
||||||
|
|
||||||
return (isTemp,filename)
|
|
||||||
|
|
||||||
def getEmbyRatingFromKodiRating(rating):
|
|
||||||
# Translation needed between Kodi/ID3 rating and emby likes/favourites:
|
|
||||||
# 3+ rating in ID3 = emby like
|
|
||||||
# 5+ rating in ID3 = emby favourite
|
|
||||||
# rating 0 = emby dislike
|
|
||||||
# rating 1-2 = emby no likes or dislikes (returns 1 in results)
|
|
||||||
favourite = False
|
|
||||||
deletelike = False
|
|
||||||
like = False
|
|
||||||
if (rating >= 3): like = True
|
|
||||||
if (rating == 0): like = False
|
|
||||||
if (rating == 1 or rating == 2): deletelike = True
|
|
||||||
if (rating >= 5): favourite = True
|
|
||||||
return(like, favourite, deletelike)
|
|
||||||
|
|
||||||
def getAdditionalSongTags(embyid, emby_rating, API, kodicursor, emby_db, enableimportsongrating, enableexportsongrating, enableupdatesongrating):
|
|
||||||
|
|
||||||
emby = embyserver.Read_EmbyServer()
|
|
||||||
|
|
||||||
previous_values = None
|
|
||||||
filename = API.getFilePath()
|
|
||||||
rating = 0
|
|
||||||
emby_rating = int(round(emby_rating, 0))
|
|
||||||
|
|
||||||
#get file rating and comment tag from file itself.
|
|
||||||
if enableimportsongrating:
|
|
||||||
file_rating, comment, hasEmbeddedCover = getSongTags(filename)
|
|
||||||
else:
|
|
||||||
file_rating = 0
|
|
||||||
comment = ""
|
|
||||||
hasEmbeddedCover = False
|
|
||||||
|
|
||||||
|
|
||||||
emby_dbitem = emby_db.getItem_byId(embyid)
|
|
||||||
try:
|
|
||||||
kodiid = emby_dbitem[0]
|
|
||||||
except TypeError:
|
|
||||||
# Item is not in database.
|
|
||||||
currentvalue = None
|
|
||||||
else:
|
|
||||||
query = "SELECT rating FROM song WHERE idSong = ?"
|
|
||||||
kodicursor.execute(query, (kodiid,))
|
|
||||||
try:
|
|
||||||
currentvalue = int(round(float(kodicursor.fetchone()[0]),0))
|
|
||||||
except: currentvalue = None
|
|
||||||
|
|
||||||
# Only proceed if we actually have a rating from the file
|
|
||||||
if file_rating is None and currentvalue:
|
|
||||||
return (currentvalue, comment, False)
|
|
||||||
elif file_rating is None and not currentvalue:
|
|
||||||
return (emby_rating, comment, False)
|
|
||||||
|
|
||||||
log.info("getAdditionalSongTags --> embyid: %s - emby_rating: %s - file_rating: %s - current rating in kodidb: %s" %(embyid, emby_rating, file_rating, currentvalue))
|
|
||||||
|
|
||||||
updateFileRating = False
|
|
||||||
updateEmbyRating = False
|
|
||||||
|
|
||||||
if currentvalue != None:
|
|
||||||
# we need to translate the emby values...
|
|
||||||
if emby_rating == 1 and currentvalue == 2:
|
|
||||||
emby_rating = 2
|
|
||||||
if emby_rating == 3 and currentvalue == 4:
|
|
||||||
emby_rating = 4
|
|
||||||
|
|
||||||
#if updating rating into file is disabled, we ignore the rating in the file...
|
|
||||||
if not enableupdatesongrating:
|
|
||||||
file_rating = currentvalue
|
|
||||||
#if convert emby likes/favourites convert to song rating is disabled, we ignore the emby rating...
|
|
||||||
if not enableexportsongrating:
|
|
||||||
emby_rating = currentvalue
|
|
||||||
|
|
||||||
if (emby_rating == file_rating) and (file_rating != currentvalue):
|
|
||||||
#the rating has been updated from kodi itself, update change to both emby ands file
|
|
||||||
rating = currentvalue
|
|
||||||
updateFileRating = True
|
|
||||||
updateEmbyRating = True
|
|
||||||
elif (emby_rating != currentvalue) and (file_rating == currentvalue):
|
|
||||||
#emby rating changed - update the file
|
|
||||||
rating = emby_rating
|
|
||||||
updateFileRating = True
|
|
||||||
elif (file_rating != currentvalue) and (emby_rating == currentvalue):
|
|
||||||
#file rating was updated, sync change to emby
|
|
||||||
rating = file_rating
|
|
||||||
updateEmbyRating = True
|
|
||||||
elif (emby_rating != currentvalue) and (file_rating != currentvalue):
|
|
||||||
#both ratings have changed (corner case) - the highest rating wins...
|
|
||||||
if emby_rating > file_rating:
|
|
||||||
rating = emby_rating
|
|
||||||
updateFileRating = True
|
|
||||||
else:
|
|
||||||
rating = file_rating
|
|
||||||
updateEmbyRating = True
|
|
||||||
else:
|
|
||||||
#nothing has changed, just return the current value
|
|
||||||
rating = currentvalue
|
|
||||||
else:
|
|
||||||
# no rating yet in DB
|
|
||||||
if enableimportsongrating:
|
|
||||||
#prefer the file rating
|
|
||||||
rating = file_rating
|
|
||||||
#determine if we should also send the rating to emby server
|
|
||||||
if enableexportsongrating:
|
|
||||||
if emby_rating == 1 and file_rating == 2:
|
|
||||||
emby_rating = 2
|
|
||||||
if emby_rating == 3 and file_rating == 4:
|
|
||||||
emby_rating = 4
|
|
||||||
if emby_rating != file_rating:
|
|
||||||
updateEmbyRating = True
|
|
||||||
|
|
||||||
elif enableexportsongrating:
|
|
||||||
#set the initial rating to emby value
|
|
||||||
rating = emby_rating
|
|
||||||
|
|
||||||
if updateFileRating and enableupdatesongrating:
|
|
||||||
updateRatingToFile(rating, filename)
|
|
||||||
|
|
||||||
if updateEmbyRating and enableexportsongrating:
|
|
||||||
# sync details to emby server. Translation needed between ID3 rating and emby likes/favourites:
|
|
||||||
like, favourite, deletelike = getEmbyRatingFromKodiRating(rating)
|
|
||||||
window("ignore-update-%s" %embyid, "true") #set temp windows prop to ignore the update from webclient update
|
|
||||||
emby.updateUserRating(embyid, favourite)
|
|
||||||
|
|
||||||
return (rating, comment, hasEmbeddedCover)
|
|
||||||
|
|
||||||
def getSongTags(file):
|
|
||||||
# Get the actual ID3 tags for music songs as the server is lacking that info
|
|
||||||
rating = 0
|
|
||||||
comment = ""
|
|
||||||
hasEmbeddedCover = False
|
|
||||||
|
|
||||||
isTemp,filename = getRealFileName(file)
|
|
||||||
log.info( "getting song ID3 tags for " + filename)
|
|
||||||
|
|
||||||
try:
|
|
||||||
###### FLAC FILES #############
|
|
||||||
if filename.lower().endswith(".flac"):
|
|
||||||
audio = FLAC(filename)
|
|
||||||
if audio.get("comment"):
|
|
||||||
comment = audio.get("comment")[0]
|
|
||||||
for pic in audio.pictures:
|
|
||||||
if pic.type == 3 and pic.data:
|
|
||||||
#the file has an embedded cover
|
|
||||||
hasEmbeddedCover = True
|
|
||||||
break
|
|
||||||
if audio.get("rating"):
|
|
||||||
rating = float(audio.get("rating")[0])
|
|
||||||
#flac rating is 0-100 and needs to be converted to 0-5 range
|
|
||||||
if rating > 5: rating = (rating / 100) * 5
|
|
||||||
|
|
||||||
###### MP3 FILES #############
|
|
||||||
elif filename.lower().endswith(".mp3"):
|
|
||||||
audio = ID3(filename)
|
|
||||||
|
|
||||||
if audio.get("APIC:Front Cover"):
|
|
||||||
if audio.get("APIC:Front Cover").data:
|
|
||||||
hasEmbeddedCover = True
|
|
||||||
|
|
||||||
if audio.get("comment"):
|
|
||||||
comment = audio.get("comment")[0]
|
|
||||||
if audio.get("POPM:Windows Media Player 9 Series"):
|
|
||||||
if audio.get("POPM:Windows Media Player 9 Series").rating:
|
|
||||||
rating = float(audio.get("POPM:Windows Media Player 9 Series").rating)
|
|
||||||
#POPM rating is 0-255 and needs to be converted to 0-5 range
|
|
||||||
if rating > 5: rating = (rating / 255) * 5
|
|
||||||
else:
|
|
||||||
log.info( "Not supported fileformat or unable to access file: %s" %(filename))
|
|
||||||
|
|
||||||
#the rating must be a round value
|
|
||||||
rating = int(round(rating,0))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
#file in use ?
|
|
||||||
log.error("Exception in getSongTags %s" % e)
|
|
||||||
rating = None
|
|
||||||
|
|
||||||
#remove tempfile if needed....
|
|
||||||
if isTemp: xbmcvfs.delete(filename)
|
|
||||||
|
|
||||||
return (rating, comment, hasEmbeddedCover)
|
|
||||||
|
|
||||||
def updateRatingToFile(rating, file):
|
|
||||||
#update the rating from Emby to the file
|
|
||||||
|
|
||||||
f = xbmcvfs.File(file)
|
|
||||||
org_size = f.size()
|
|
||||||
f.close()
|
|
||||||
|
|
||||||
#create tempfile
|
|
||||||
if "/" in file: filepart = file.split("/")[-1]
|
|
||||||
else: filepart = file.split("\\")[-1]
|
|
||||||
tempfile = "special://temp/"+filepart
|
|
||||||
xbmcvfs.copy(file, tempfile)
|
|
||||||
tempfile = xbmc.translatePath(tempfile).decode("utf-8")
|
|
||||||
|
|
||||||
log.info( "setting song rating: %s for filename: %s - using tempfile: %s" %(rating,file,tempfile))
|
|
||||||
|
|
||||||
if not tempfile:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
if tempfile.lower().endswith(".flac"):
|
|
||||||
audio = FLAC(tempfile)
|
|
||||||
calcrating = int(round((float(rating) / 5) * 100, 0))
|
|
||||||
audio["rating"] = str(calcrating)
|
|
||||||
audio.save()
|
|
||||||
elif tempfile.lower().endswith(".mp3"):
|
|
||||||
audio = ID3(tempfile)
|
|
||||||
calcrating = int(round((float(rating) / 5) * 255, 0))
|
|
||||||
audio.add(id3.POPM(email="Windows Media Player 9 Series", rating=calcrating, count=1))
|
|
||||||
audio.save()
|
|
||||||
else:
|
|
||||||
log.info( "Not supported fileformat: %s" %(tempfile))
|
|
||||||
|
|
||||||
#once we have succesfully written the flags we move the temp file to destination, otherwise not proceeding and just delete the temp
|
|
||||||
#safety check: we check the file size of the temp file before proceeding with overwite of original file
|
|
||||||
f = xbmcvfs.File(tempfile)
|
|
||||||
checksum_size = f.size()
|
|
||||||
f.close()
|
|
||||||
if checksum_size >= org_size:
|
|
||||||
xbmcvfs.delete(file)
|
|
||||||
xbmcvfs.copy(tempfile,file)
|
|
||||||
else:
|
|
||||||
log.info( "Checksum mismatch for filename: %s - using tempfile: %s - not proceeding with file overwite!" %(rating,file,tempfile))
|
|
||||||
|
|
||||||
#always delete the tempfile
|
|
||||||
xbmcvfs.delete(tempfile)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
#file in use ?
|
|
||||||
log.error("Exception in updateRatingToFile %s" % e)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,43 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2005 Michael Urman
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of version 2 of the GNU General Public License as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
|
|
||||||
"""Mutagen aims to be an all purpose multimedia tagging library.
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
import mutagen.[format]
|
|
||||||
metadata = mutagen.[format].Open(filename)
|
|
||||||
|
|
||||||
`metadata` acts like a dictionary of tags in the file. Tags are generally a
|
|
||||||
list of string-like values, but may have additional methods available
|
|
||||||
depending on tag or format. They may also be entirely different objects
|
|
||||||
for certain keys, again depending on format.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from mutagen._util import MutagenError
|
|
||||||
from mutagen._file import FileType, StreamInfo, File
|
|
||||||
from mutagen._tags import Metadata, PaddingInfo
|
|
||||||
|
|
||||||
version = (1, 31)
|
|
||||||
"""Version tuple."""
|
|
||||||
|
|
||||||
version_string = ".".join(map(str, version))
|
|
||||||
"""Version string."""
|
|
||||||
|
|
||||||
MutagenError
|
|
||||||
|
|
||||||
FileType
|
|
||||||
|
|
||||||
StreamInfo
|
|
||||||
|
|
||||||
File
|
|
||||||
|
|
||||||
Metadata
|
|
||||||
|
|
||||||
PaddingInfo
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -1,86 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2013 Christoph Reiter
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of version 2 of the GNU General Public License as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
PY2 = sys.version_info[0] == 2
|
|
||||||
PY3 = not PY2
|
|
||||||
|
|
||||||
if PY2:
|
|
||||||
from StringIO import StringIO
|
|
||||||
BytesIO = StringIO
|
|
||||||
from cStringIO import StringIO as cBytesIO
|
|
||||||
from itertools import izip
|
|
||||||
|
|
||||||
long_ = long
|
|
||||||
integer_types = (int, long)
|
|
||||||
string_types = (str, unicode)
|
|
||||||
text_type = unicode
|
|
||||||
|
|
||||||
xrange = xrange
|
|
||||||
cmp = cmp
|
|
||||||
chr_ = chr
|
|
||||||
|
|
||||||
def endswith(text, end):
|
|
||||||
return text.endswith(end)
|
|
||||||
|
|
||||||
iteritems = lambda d: d.iteritems()
|
|
||||||
itervalues = lambda d: d.itervalues()
|
|
||||||
iterkeys = lambda d: d.iterkeys()
|
|
||||||
|
|
||||||
iterbytes = lambda b: iter(b)
|
|
||||||
|
|
||||||
exec("def reraise(tp, value, tb):\n raise tp, value, tb")
|
|
||||||
|
|
||||||
def swap_to_string(cls):
|
|
||||||
if "__str__" in cls.__dict__:
|
|
||||||
cls.__unicode__ = cls.__str__
|
|
||||||
|
|
||||||
if "__bytes__" in cls.__dict__:
|
|
||||||
cls.__str__ = cls.__bytes__
|
|
||||||
|
|
||||||
return cls
|
|
||||||
|
|
||||||
elif PY3:
|
|
||||||
from io import StringIO
|
|
||||||
StringIO = StringIO
|
|
||||||
from io import BytesIO
|
|
||||||
cBytesIO = BytesIO
|
|
||||||
|
|
||||||
long_ = int
|
|
||||||
integer_types = (int,)
|
|
||||||
string_types = (str,)
|
|
||||||
text_type = str
|
|
||||||
|
|
||||||
izip = zip
|
|
||||||
xrange = range
|
|
||||||
cmp = lambda a, b: (a > b) - (a < b)
|
|
||||||
chr_ = lambda x: bytes([x])
|
|
||||||
|
|
||||||
def endswith(text, end):
|
|
||||||
# usefull for paths which can be both, str and bytes
|
|
||||||
if isinstance(text, str):
|
|
||||||
if not isinstance(end, str):
|
|
||||||
end = end.decode("ascii")
|
|
||||||
else:
|
|
||||||
if not isinstance(end, bytes):
|
|
||||||
end = end.encode("ascii")
|
|
||||||
return text.endswith(end)
|
|
||||||
|
|
||||||
iteritems = lambda d: iter(d.items())
|
|
||||||
itervalues = lambda d: iter(d.values())
|
|
||||||
iterkeys = lambda d: iter(d.keys())
|
|
||||||
|
|
||||||
iterbytes = lambda b: (bytes([v]) for v in b)
|
|
||||||
|
|
||||||
def reraise(tp, value, tb):
|
|
||||||
raise tp(value).with_traceback(tb)
|
|
||||||
|
|
||||||
def swap_to_string(cls):
|
|
||||||
return cls
|
|
|
@ -1,199 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""Constants used by Mutagen."""
|
|
||||||
|
|
||||||
GENRES = [
|
|
||||||
u"Blues",
|
|
||||||
u"Classic Rock",
|
|
||||||
u"Country",
|
|
||||||
u"Dance",
|
|
||||||
u"Disco",
|
|
||||||
u"Funk",
|
|
||||||
u"Grunge",
|
|
||||||
u"Hip-Hop",
|
|
||||||
u"Jazz",
|
|
||||||
u"Metal",
|
|
||||||
u"New Age",
|
|
||||||
u"Oldies",
|
|
||||||
u"Other",
|
|
||||||
u"Pop",
|
|
||||||
u"R&B",
|
|
||||||
u"Rap",
|
|
||||||
u"Reggae",
|
|
||||||
u"Rock",
|
|
||||||
u"Techno",
|
|
||||||
u"Industrial",
|
|
||||||
u"Alternative",
|
|
||||||
u"Ska",
|
|
||||||
u"Death Metal",
|
|
||||||
u"Pranks",
|
|
||||||
u"Soundtrack",
|
|
||||||
u"Euro-Techno",
|
|
||||||
u"Ambient",
|
|
||||||
u"Trip-Hop",
|
|
||||||
u"Vocal",
|
|
||||||
u"Jazz+Funk",
|
|
||||||
u"Fusion",
|
|
||||||
u"Trance",
|
|
||||||
u"Classical",
|
|
||||||
u"Instrumental",
|
|
||||||
u"Acid",
|
|
||||||
u"House",
|
|
||||||
u"Game",
|
|
||||||
u"Sound Clip",
|
|
||||||
u"Gospel",
|
|
||||||
u"Noise",
|
|
||||||
u"Alt. Rock",
|
|
||||||
u"Bass",
|
|
||||||
u"Soul",
|
|
||||||
u"Punk",
|
|
||||||
u"Space",
|
|
||||||
u"Meditative",
|
|
||||||
u"Instrumental Pop",
|
|
||||||
u"Instrumental Rock",
|
|
||||||
u"Ethnic",
|
|
||||||
u"Gothic",
|
|
||||||
u"Darkwave",
|
|
||||||
u"Techno-Industrial",
|
|
||||||
u"Electronic",
|
|
||||||
u"Pop-Folk",
|
|
||||||
u"Eurodance",
|
|
||||||
u"Dream",
|
|
||||||
u"Southern Rock",
|
|
||||||
u"Comedy",
|
|
||||||
u"Cult",
|
|
||||||
u"Gangsta Rap",
|
|
||||||
u"Top 40",
|
|
||||||
u"Christian Rap",
|
|
||||||
u"Pop/Funk",
|
|
||||||
u"Jungle",
|
|
||||||
u"Native American",
|
|
||||||
u"Cabaret",
|
|
||||||
u"New Wave",
|
|
||||||
u"Psychedelic",
|
|
||||||
u"Rave",
|
|
||||||
u"Showtunes",
|
|
||||||
u"Trailer",
|
|
||||||
u"Lo-Fi",
|
|
||||||
u"Tribal",
|
|
||||||
u"Acid Punk",
|
|
||||||
u"Acid Jazz",
|
|
||||||
u"Polka",
|
|
||||||
u"Retro",
|
|
||||||
u"Musical",
|
|
||||||
u"Rock & Roll",
|
|
||||||
u"Hard Rock",
|
|
||||||
u"Folk",
|
|
||||||
u"Folk-Rock",
|
|
||||||
u"National Folk",
|
|
||||||
u"Swing",
|
|
||||||
u"Fast-Fusion",
|
|
||||||
u"Bebop",
|
|
||||||
u"Latin",
|
|
||||||
u"Revival",
|
|
||||||
u"Celtic",
|
|
||||||
u"Bluegrass",
|
|
||||||
u"Avantgarde",
|
|
||||||
u"Gothic Rock",
|
|
||||||
u"Progressive Rock",
|
|
||||||
u"Psychedelic Rock",
|
|
||||||
u"Symphonic Rock",
|
|
||||||
u"Slow Rock",
|
|
||||||
u"Big Band",
|
|
||||||
u"Chorus",
|
|
||||||
u"Easy Listening",
|
|
||||||
u"Acoustic",
|
|
||||||
u"Humour",
|
|
||||||
u"Speech",
|
|
||||||
u"Chanson",
|
|
||||||
u"Opera",
|
|
||||||
u"Chamber Music",
|
|
||||||
u"Sonata",
|
|
||||||
u"Symphony",
|
|
||||||
u"Booty Bass",
|
|
||||||
u"Primus",
|
|
||||||
u"Porn Groove",
|
|
||||||
u"Satire",
|
|
||||||
u"Slow Jam",
|
|
||||||
u"Club",
|
|
||||||
u"Tango",
|
|
||||||
u"Samba",
|
|
||||||
u"Folklore",
|
|
||||||
u"Ballad",
|
|
||||||
u"Power Ballad",
|
|
||||||
u"Rhythmic Soul",
|
|
||||||
u"Freestyle",
|
|
||||||
u"Duet",
|
|
||||||
u"Punk Rock",
|
|
||||||
u"Drum Solo",
|
|
||||||
u"A Cappella",
|
|
||||||
u"Euro-House",
|
|
||||||
u"Dance Hall",
|
|
||||||
u"Goa",
|
|
||||||
u"Drum & Bass",
|
|
||||||
u"Club-House",
|
|
||||||
u"Hardcore",
|
|
||||||
u"Terror",
|
|
||||||
u"Indie",
|
|
||||||
u"BritPop",
|
|
||||||
u"Afro-Punk",
|
|
||||||
u"Polsk Punk",
|
|
||||||
u"Beat",
|
|
||||||
u"Christian Gangsta Rap",
|
|
||||||
u"Heavy Metal",
|
|
||||||
u"Black Metal",
|
|
||||||
u"Crossover",
|
|
||||||
u"Contemporary Christian",
|
|
||||||
u"Christian Rock",
|
|
||||||
u"Merengue",
|
|
||||||
u"Salsa",
|
|
||||||
u"Thrash Metal",
|
|
||||||
u"Anime",
|
|
||||||
u"JPop",
|
|
||||||
u"Synthpop",
|
|
||||||
u"Abstract",
|
|
||||||
u"Art Rock",
|
|
||||||
u"Baroque",
|
|
||||||
u"Bhangra",
|
|
||||||
u"Big Beat",
|
|
||||||
u"Breakbeat",
|
|
||||||
u"Chillout",
|
|
||||||
u"Downtempo",
|
|
||||||
u"Dub",
|
|
||||||
u"EBM",
|
|
||||||
u"Eclectic",
|
|
||||||
u"Electro",
|
|
||||||
u"Electroclash",
|
|
||||||
u"Emo",
|
|
||||||
u"Experimental",
|
|
||||||
u"Garage",
|
|
||||||
u"Global",
|
|
||||||
u"IDM",
|
|
||||||
u"Illbient",
|
|
||||||
u"Industro-Goth",
|
|
||||||
u"Jam Band",
|
|
||||||
u"Krautrock",
|
|
||||||
u"Leftfield",
|
|
||||||
u"Lounge",
|
|
||||||
u"Math Rock",
|
|
||||||
u"New Romantic",
|
|
||||||
u"Nu-Breakz",
|
|
||||||
u"Post-Punk",
|
|
||||||
u"Post-Rock",
|
|
||||||
u"Psytrance",
|
|
||||||
u"Shoegaze",
|
|
||||||
u"Space Rock",
|
|
||||||
u"Trop Rock",
|
|
||||||
u"World Music",
|
|
||||||
u"Neoclassical",
|
|
||||||
u"Audiobook",
|
|
||||||
u"Audio Theatre",
|
|
||||||
u"Neue Deutsche Welle",
|
|
||||||
u"Podcast",
|
|
||||||
u"Indie Rock",
|
|
||||||
u"G-Funk",
|
|
||||||
u"Dubstep",
|
|
||||||
u"Garage Rock",
|
|
||||||
u"Psybient",
|
|
||||||
]
|
|
||||||
"""The ID3v1 genre list."""
|
|
|
@ -1,253 +0,0 @@
|
||||||
# Copyright (C) 2005 Michael Urman
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of version 2 of the GNU General Public License as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
from mutagen._util import DictMixin
|
|
||||||
from mutagen._compat import izip
|
|
||||||
|
|
||||||
|
|
||||||
class FileType(DictMixin):
|
|
||||||
"""An abstract object wrapping tags and audio stream information.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
* info -- stream information (length, bitrate, sample rate)
|
|
||||||
* tags -- metadata tags, if any
|
|
||||||
|
|
||||||
Each file format has different potential tags and stream
|
|
||||||
information.
|
|
||||||
|
|
||||||
FileTypes implement an interface very similar to Metadata; the
|
|
||||||
dict interface, save, load, and delete calls on a FileType call
|
|
||||||
the appropriate methods on its tag data.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__module__ = "mutagen"
|
|
||||||
|
|
||||||
info = None
|
|
||||||
tags = None
|
|
||||||
filename = None
|
|
||||||
_mimes = ["application/octet-stream"]
|
|
||||||
|
|
||||||
def __init__(self, filename=None, *args, **kwargs):
|
|
||||||
if filename is None:
|
|
||||||
warnings.warn("FileType constructor requires a filename",
|
|
||||||
DeprecationWarning)
|
|
||||||
else:
|
|
||||||
self.load(filename, *args, **kwargs)
|
|
||||||
|
|
||||||
def load(self, filename, *args, **kwargs):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
"""Look up a metadata tag key.
|
|
||||||
|
|
||||||
If the file has no tags at all, a KeyError is raised.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.tags is None:
|
|
||||||
raise KeyError(key)
|
|
||||||
else:
|
|
||||||
return self.tags[key]
|
|
||||||
|
|
||||||
def __setitem__(self, key, value):
|
|
||||||
"""Set a metadata tag.
|
|
||||||
|
|
||||||
If the file has no tags, an appropriate format is added (but
|
|
||||||
not written until save is called).
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.tags is None:
|
|
||||||
self.add_tags()
|
|
||||||
self.tags[key] = value
|
|
||||||
|
|
||||||
def __delitem__(self, key):
|
|
||||||
"""Delete a metadata tag key.
|
|
||||||
|
|
||||||
If the file has no tags at all, a KeyError is raised.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.tags is None:
|
|
||||||
raise KeyError(key)
|
|
||||||
else:
|
|
||||||
del(self.tags[key])
|
|
||||||
|
|
||||||
def keys(self):
|
|
||||||
"""Return a list of keys in the metadata tag.
|
|
||||||
|
|
||||||
If the file has no tags at all, an empty list is returned.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.tags is None:
|
|
||||||
return []
|
|
||||||
else:
|
|
||||||
return self.tags.keys()
|
|
||||||
|
|
||||||
def delete(self, filename=None):
|
|
||||||
"""Remove tags from a file.
|
|
||||||
|
|
||||||
In cases where the tagging format is independent of the file type
|
|
||||||
(for example `mutagen.ID3`) all traces of the tagging format will
|
|
||||||
be removed.
|
|
||||||
In cases where the tag is part of the file type, all tags and
|
|
||||||
padding will be removed.
|
|
||||||
|
|
||||||
The tags attribute will be cleared as well if there is one.
|
|
||||||
|
|
||||||
Does nothing if the file has no tags.
|
|
||||||
|
|
||||||
:raises mutagen.MutagenError: if deleting wasn't possible
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.tags is not None:
|
|
||||||
if filename is None:
|
|
||||||
filename = self.filename
|
|
||||||
else:
|
|
||||||
warnings.warn(
|
|
||||||
"delete(filename=...) is deprecated, reload the file",
|
|
||||||
DeprecationWarning)
|
|
||||||
return self.tags.delete(filename)
|
|
||||||
|
|
||||||
def save(self, filename=None, **kwargs):
|
|
||||||
"""Save metadata tags.
|
|
||||||
|
|
||||||
:raises mutagen.MutagenError: if saving wasn't possible
|
|
||||||
"""
|
|
||||||
|
|
||||||
if filename is None:
|
|
||||||
filename = self.filename
|
|
||||||
else:
|
|
||||||
warnings.warn(
|
|
||||||
"save(filename=...) is deprecated, reload the file",
|
|
||||||
DeprecationWarning)
|
|
||||||
|
|
||||||
if self.tags is not None:
|
|
||||||
return self.tags.save(filename, **kwargs)
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
"""Print stream information and comment key=value pairs."""
|
|
||||||
|
|
||||||
stream = "%s (%s)" % (self.info.pprint(), self.mime[0])
|
|
||||||
try:
|
|
||||||
tags = self.tags.pprint()
|
|
||||||
except AttributeError:
|
|
||||||
return stream
|
|
||||||
else:
|
|
||||||
return stream + ((tags and "\n" + tags) or "")
|
|
||||||
|
|
||||||
def add_tags(self):
|
|
||||||
"""Adds new tags to the file.
|
|
||||||
|
|
||||||
:raises mutagen.MutagenError: if tags already exist or adding is not
|
|
||||||
possible.
|
|
||||||
"""
|
|
||||||
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@property
|
|
||||||
def mime(self):
|
|
||||||
"""A list of mime types"""
|
|
||||||
|
|
||||||
mimes = []
|
|
||||||
for Kind in type(self).__mro__:
|
|
||||||
for mime in getattr(Kind, '_mimes', []):
|
|
||||||
if mime not in mimes:
|
|
||||||
mimes.append(mime)
|
|
||||||
return mimes
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score(filename, fileobj, header):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class StreamInfo(object):
|
|
||||||
"""Abstract stream information object.
|
|
||||||
|
|
||||||
Provides attributes for length, bitrate, sample rate etc.
|
|
||||||
|
|
||||||
See the implementations for details.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__module__ = "mutagen"
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
"""Print stream information"""
|
|
||||||
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def File(filename, options=None, easy=False):
|
|
||||||
"""Guess the type of the file and try to open it.
|
|
||||||
|
|
||||||
The file type is decided by several things, such as the first 128
|
|
||||||
bytes (which usually contains a file type identifier), the
|
|
||||||
filename extension, and the presence of existing tags.
|
|
||||||
|
|
||||||
If no appropriate type could be found, None is returned.
|
|
||||||
|
|
||||||
:param options: Sequence of :class:`FileType` implementations, defaults to
|
|
||||||
all included ones.
|
|
||||||
|
|
||||||
:param easy: If the easy wrappers should be returnd if available.
|
|
||||||
For example :class:`EasyMP3 <mp3.EasyMP3>` instead
|
|
||||||
of :class:`MP3 <mp3.MP3>`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if options is None:
|
|
||||||
from mutagen.asf import ASF
|
|
||||||
from mutagen.apev2 import APEv2File
|
|
||||||
from mutagen.flac import FLAC
|
|
||||||
if easy:
|
|
||||||
from mutagen.easyid3 import EasyID3FileType as ID3FileType
|
|
||||||
else:
|
|
||||||
from mutagen.id3 import ID3FileType
|
|
||||||
if easy:
|
|
||||||
from mutagen.mp3 import EasyMP3 as MP3
|
|
||||||
else:
|
|
||||||
from mutagen.mp3 import MP3
|
|
||||||
from mutagen.oggflac import OggFLAC
|
|
||||||
from mutagen.oggspeex import OggSpeex
|
|
||||||
from mutagen.oggtheora import OggTheora
|
|
||||||
from mutagen.oggvorbis import OggVorbis
|
|
||||||
from mutagen.oggopus import OggOpus
|
|
||||||
if easy:
|
|
||||||
from mutagen.trueaudio import EasyTrueAudio as TrueAudio
|
|
||||||
else:
|
|
||||||
from mutagen.trueaudio import TrueAudio
|
|
||||||
from mutagen.wavpack import WavPack
|
|
||||||
if easy:
|
|
||||||
from mutagen.easymp4 import EasyMP4 as MP4
|
|
||||||
else:
|
|
||||||
from mutagen.mp4 import MP4
|
|
||||||
from mutagen.musepack import Musepack
|
|
||||||
from mutagen.monkeysaudio import MonkeysAudio
|
|
||||||
from mutagen.optimfrog import OptimFROG
|
|
||||||
from mutagen.aiff import AIFF
|
|
||||||
from mutagen.aac import AAC
|
|
||||||
options = [MP3, TrueAudio, OggTheora, OggSpeex, OggVorbis, OggFLAC,
|
|
||||||
FLAC, AIFF, APEv2File, MP4, ID3FileType, WavPack,
|
|
||||||
Musepack, MonkeysAudio, OptimFROG, ASF, OggOpus, AAC]
|
|
||||||
|
|
||||||
if not options:
|
|
||||||
return None
|
|
||||||
|
|
||||||
with open(filename, "rb") as fileobj:
|
|
||||||
header = fileobj.read(128)
|
|
||||||
# Sort by name after score. Otherwise import order affects
|
|
||||||
# Kind sort order, which affects treatment of things with
|
|
||||||
# equals scores.
|
|
||||||
results = [(Kind.score(filename, fileobj, header), Kind.__name__)
|
|
||||||
for Kind in options]
|
|
||||||
|
|
||||||
results = list(izip(results, options))
|
|
||||||
results.sort()
|
|
||||||
(score, name), Kind = results[-1]
|
|
||||||
if score > 0:
|
|
||||||
return Kind(filename)
|
|
||||||
else:
|
|
||||||
return None
|
|
|
@ -1,420 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright 2015 Christoph Reiter
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of version 2 of the GNU General Public License as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""
|
|
||||||
http://www.codeproject.com/Articles/8295/MPEG-Audio-Frame-Header
|
|
||||||
http://wiki.hydrogenaud.io/index.php?title=MP3
|
|
||||||
"""
|
|
||||||
|
|
||||||
from functools import partial
|
|
||||||
|
|
||||||
from ._util import cdata, BitReader
|
|
||||||
from ._compat import xrange, iterbytes, cBytesIO
|
|
||||||
|
|
||||||
|
|
||||||
class LAMEError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class LAMEHeader(object):
|
|
||||||
"""http://gabriel.mp3-tech.org/mp3infotag.html"""
|
|
||||||
|
|
||||||
vbr_method = 0
|
|
||||||
"""0: unknown, 1: CBR, 2: ABR, 3/4/5: VBR, others: see the docs"""
|
|
||||||
|
|
||||||
lowpass_filter = 0
|
|
||||||
"""lowpass filter value in Hz. 0 means unknown"""
|
|
||||||
|
|
||||||
quality = -1
|
|
||||||
"""Encoding quality: 0..9"""
|
|
||||||
|
|
||||||
vbr_quality = -1
|
|
||||||
"""VBR quality: 0..9"""
|
|
||||||
|
|
||||||
track_peak = None
|
|
||||||
"""Peak signal amplitude as float. None if unknown."""
|
|
||||||
|
|
||||||
track_gain_origin = 0
|
|
||||||
"""see the docs"""
|
|
||||||
|
|
||||||
track_gain_adjustment = None
|
|
||||||
"""Track gain adjustment as float (for 89db replay gain) or None"""
|
|
||||||
|
|
||||||
album_gain_origin = 0
|
|
||||||
"""see the docs"""
|
|
||||||
|
|
||||||
album_gain_adjustment = None
|
|
||||||
"""Album gain adjustment as float (for 89db replay gain) or None"""
|
|
||||||
|
|
||||||
encoding_flags = 0
|
|
||||||
"""see docs"""
|
|
||||||
|
|
||||||
ath_type = -1
|
|
||||||
"""see docs"""
|
|
||||||
|
|
||||||
bitrate = -1
|
|
||||||
"""Bitrate in kbps. For VBR the minimum bitrate, for anything else
|
|
||||||
(CBR, ABR, ..) the target bitrate.
|
|
||||||
"""
|
|
||||||
|
|
||||||
encoder_delay_start = 0
|
|
||||||
"""Encoder delay in samples"""
|
|
||||||
|
|
||||||
encoder_padding_end = 0
|
|
||||||
"""Padding in samples added at the end"""
|
|
||||||
|
|
||||||
source_sample_frequency_enum = -1
|
|
||||||
"""see docs"""
|
|
||||||
|
|
||||||
unwise_setting_used = False
|
|
||||||
"""see docs"""
|
|
||||||
|
|
||||||
stereo_mode = 0
|
|
||||||
"""see docs"""
|
|
||||||
|
|
||||||
noise_shaping = 0
|
|
||||||
"""see docs"""
|
|
||||||
|
|
||||||
mp3_gain = 0
|
|
||||||
"""Applied MP3 gain -127..127. Factor is 2 ** (mp3_gain / 4)"""
|
|
||||||
|
|
||||||
surround_info = 0
|
|
||||||
"""see docs"""
|
|
||||||
|
|
||||||
preset_used = 0
|
|
||||||
"""lame preset"""
|
|
||||||
|
|
||||||
music_length = 0
|
|
||||||
"""Length in bytes excluding any ID3 tags"""
|
|
||||||
|
|
||||||
music_crc = -1
|
|
||||||
"""CRC16 of the data specified by music_length"""
|
|
||||||
|
|
||||||
header_crc = -1
|
|
||||||
"""CRC16 of this header and everything before (not checked)"""
|
|
||||||
|
|
||||||
def __init__(self, xing, fileobj):
|
|
||||||
"""Raises LAMEError if parsing fails"""
|
|
||||||
|
|
||||||
payload = fileobj.read(27)
|
|
||||||
if len(payload) != 27:
|
|
||||||
raise LAMEError("Not enough data")
|
|
||||||
|
|
||||||
# extended lame header
|
|
||||||
r = BitReader(cBytesIO(payload))
|
|
||||||
revision = r.bits(4)
|
|
||||||
if revision != 0:
|
|
||||||
raise LAMEError("unsupported header revision %d" % revision)
|
|
||||||
|
|
||||||
self.vbr_method = r.bits(4)
|
|
||||||
self.lowpass_filter = r.bits(8) * 100
|
|
||||||
|
|
||||||
# these have a different meaning for lame; expose them again here
|
|
||||||
self.quality = (100 - xing.vbr_scale) % 10
|
|
||||||
self.vbr_quality = (100 - xing.vbr_scale) // 10
|
|
||||||
|
|
||||||
track_peak_data = r.bytes(4)
|
|
||||||
if track_peak_data == b"\x00\x00\x00\x00":
|
|
||||||
self.track_peak = None
|
|
||||||
else:
|
|
||||||
# see PutLameVBR() in LAME's VbrTag.c
|
|
||||||
self.track_peak = (
|
|
||||||
cdata.uint32_be(track_peak_data) - 0.5) / 2 ** 23
|
|
||||||
track_gain_type = r.bits(3)
|
|
||||||
self.track_gain_origin = r.bits(3)
|
|
||||||
sign = r.bits(1)
|
|
||||||
gain_adj = r.bits(9) / 10.0
|
|
||||||
if sign:
|
|
||||||
gain_adj *= -1
|
|
||||||
if track_gain_type == 1:
|
|
||||||
self.track_gain_adjustment = gain_adj
|
|
||||||
else:
|
|
||||||
self.track_gain_adjustment = None
|
|
||||||
assert r.is_aligned()
|
|
||||||
|
|
||||||
album_gain_type = r.bits(3)
|
|
||||||
self.album_gain_origin = r.bits(3)
|
|
||||||
sign = r.bits(1)
|
|
||||||
album_gain_adj = r.bits(9) / 10.0
|
|
||||||
if album_gain_type == 2:
|
|
||||||
self.album_gain_adjustment = album_gain_adj
|
|
||||||
else:
|
|
||||||
self.album_gain_adjustment = None
|
|
||||||
|
|
||||||
self.encoding_flags = r.bits(4)
|
|
||||||
self.ath_type = r.bits(4)
|
|
||||||
|
|
||||||
self.bitrate = r.bits(8)
|
|
||||||
|
|
||||||
self.encoder_delay_start = r.bits(12)
|
|
||||||
self.encoder_padding_end = r.bits(12)
|
|
||||||
|
|
||||||
self.source_sample_frequency_enum = r.bits(2)
|
|
||||||
self.unwise_setting_used = r.bits(1)
|
|
||||||
self.stereo_mode = r.bits(3)
|
|
||||||
self.noise_shaping = r.bits(2)
|
|
||||||
|
|
||||||
sign = r.bits(1)
|
|
||||||
mp3_gain = r.bits(7)
|
|
||||||
if sign:
|
|
||||||
mp3_gain *= -1
|
|
||||||
self.mp3_gain = mp3_gain
|
|
||||||
|
|
||||||
r.skip(2)
|
|
||||||
self.surround_info = r.bits(3)
|
|
||||||
self.preset_used = r.bits(11)
|
|
||||||
self.music_length = r.bits(32)
|
|
||||||
self.music_crc = r.bits(16)
|
|
||||||
|
|
||||||
self.header_crc = r.bits(16)
|
|
||||||
assert r.is_aligned()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def parse_version(cls, fileobj):
|
|
||||||
"""Returns a version string and True if a LAMEHeader follows.
|
|
||||||
The passed file object will be positioned right before the
|
|
||||||
lame header if True.
|
|
||||||
|
|
||||||
Raises LAMEError if there is no lame version info.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# http://wiki.hydrogenaud.io/index.php?title=LAME_version_string
|
|
||||||
|
|
||||||
data = fileobj.read(20)
|
|
||||||
if len(data) != 20:
|
|
||||||
raise LAMEError("Not a lame header")
|
|
||||||
if not data.startswith((b"LAME", b"L3.99")):
|
|
||||||
raise LAMEError("Not a lame header")
|
|
||||||
|
|
||||||
data = data.lstrip(b"EMAL")
|
|
||||||
major, data = data[0:1], data[1:].lstrip(b".")
|
|
||||||
minor = b""
|
|
||||||
for c in iterbytes(data):
|
|
||||||
if not c.isdigit():
|
|
||||||
break
|
|
||||||
minor += c
|
|
||||||
data = data[len(minor):]
|
|
||||||
|
|
||||||
try:
|
|
||||||
major = int(major.decode("ascii"))
|
|
||||||
minor = int(minor.decode("ascii"))
|
|
||||||
except ValueError:
|
|
||||||
raise LAMEError
|
|
||||||
|
|
||||||
# the extended header was added sometimes in the 3.90 cycle
|
|
||||||
# e.g. "LAME3.90 (alpha)" should still stop here.
|
|
||||||
# (I have seen such a file)
|
|
||||||
if (major, minor) < (3, 90) or (
|
|
||||||
(major, minor) == (3, 90) and data[-11:-10] == b"("):
|
|
||||||
flag = data.strip(b"\x00").rstrip().decode("ascii")
|
|
||||||
return u"%d.%d%s" % (major, minor, flag), False
|
|
||||||
|
|
||||||
if len(data) <= 11:
|
|
||||||
raise LAMEError("Invalid version: too long")
|
|
||||||
|
|
||||||
flag = data[:-11].rstrip(b"\x00")
|
|
||||||
|
|
||||||
flag_string = u""
|
|
||||||
patch = u""
|
|
||||||
if flag == b"a":
|
|
||||||
flag_string = u" (alpha)"
|
|
||||||
elif flag == b"b":
|
|
||||||
flag_string = u" (beta)"
|
|
||||||
elif flag == b"r":
|
|
||||||
patch = u".1+"
|
|
||||||
elif flag == b" ":
|
|
||||||
if (major, minor) > (3, 96):
|
|
||||||
patch = u".0"
|
|
||||||
else:
|
|
||||||
patch = u".0+"
|
|
||||||
elif flag == b"" or flag == b".":
|
|
||||||
patch = u".0+"
|
|
||||||
else:
|
|
||||||
flag_string = u" (?)"
|
|
||||||
|
|
||||||
# extended header, seek back to 9 bytes for the caller
|
|
||||||
fileobj.seek(-11, 1)
|
|
||||||
|
|
||||||
return u"%d.%d%s%s" % (major, minor, patch, flag_string), True
|
|
||||||
|
|
||||||
|
|
||||||
class XingHeaderError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class XingHeaderFlags(object):
|
|
||||||
FRAMES = 0x1
|
|
||||||
BYTES = 0x2
|
|
||||||
TOC = 0x4
|
|
||||||
VBR_SCALE = 0x8
|
|
||||||
|
|
||||||
|
|
||||||
class XingHeader(object):
|
|
||||||
|
|
||||||
frames = -1
|
|
||||||
"""Number of frames, -1 if unknown"""
|
|
||||||
|
|
||||||
bytes = -1
|
|
||||||
"""Number of bytes, -1 if unknown"""
|
|
||||||
|
|
||||||
toc = []
|
|
||||||
"""List of 100 file offsets in percent encoded as 0-255. E.g. entry
|
|
||||||
50 contains the file offset in percent at 50% play time.
|
|
||||||
Empty if unknown.
|
|
||||||
"""
|
|
||||||
|
|
||||||
vbr_scale = -1
|
|
||||||
"""VBR quality indicator 0-100. -1 if unknown"""
|
|
||||||
|
|
||||||
lame_header = None
|
|
||||||
"""A LAMEHeader instance or None"""
|
|
||||||
|
|
||||||
lame_version = u""
|
|
||||||
"""The version of the LAME encoder e.g. '3.99.0'. Empty if unknown"""
|
|
||||||
|
|
||||||
is_info = False
|
|
||||||
"""If the header started with 'Info' and not 'Xing'"""
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
"""Parses the Xing header or raises XingHeaderError.
|
|
||||||
|
|
||||||
The file position after this returns is undefined.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = fileobj.read(8)
|
|
||||||
if len(data) != 8 or data[:4] not in (b"Xing", b"Info"):
|
|
||||||
raise XingHeaderError("Not a Xing header")
|
|
||||||
|
|
||||||
self.is_info = (data[:4] == b"Info")
|
|
||||||
|
|
||||||
flags = cdata.uint32_be_from(data, 4)[0]
|
|
||||||
|
|
||||||
if flags & XingHeaderFlags.FRAMES:
|
|
||||||
data = fileobj.read(4)
|
|
||||||
if len(data) != 4:
|
|
||||||
raise XingHeaderError("Xing header truncated")
|
|
||||||
self.frames = cdata.uint32_be(data)
|
|
||||||
|
|
||||||
if flags & XingHeaderFlags.BYTES:
|
|
||||||
data = fileobj.read(4)
|
|
||||||
if len(data) != 4:
|
|
||||||
raise XingHeaderError("Xing header truncated")
|
|
||||||
self.bytes = cdata.uint32_be(data)
|
|
||||||
|
|
||||||
if flags & XingHeaderFlags.TOC:
|
|
||||||
data = fileobj.read(100)
|
|
||||||
if len(data) != 100:
|
|
||||||
raise XingHeaderError("Xing header truncated")
|
|
||||||
self.toc = list(bytearray(data))
|
|
||||||
|
|
||||||
if flags & XingHeaderFlags.VBR_SCALE:
|
|
||||||
data = fileobj.read(4)
|
|
||||||
if len(data) != 4:
|
|
||||||
raise XingHeaderError("Xing header truncated")
|
|
||||||
self.vbr_scale = cdata.uint32_be(data)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.lame_version, has_header = LAMEHeader.parse_version(fileobj)
|
|
||||||
if has_header:
|
|
||||||
self.lame_header = LAMEHeader(self, fileobj)
|
|
||||||
except LAMEError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_offset(cls, info):
|
|
||||||
"""Calculate the offset to the Xing header from the start of the
|
|
||||||
MPEG header including sync based on the MPEG header's content.
|
|
||||||
"""
|
|
||||||
|
|
||||||
assert info.layer == 3
|
|
||||||
|
|
||||||
if info.version == 1:
|
|
||||||
if info.mode != 3:
|
|
||||||
return 36
|
|
||||||
else:
|
|
||||||
return 21
|
|
||||||
else:
|
|
||||||
if info.mode != 3:
|
|
||||||
return 21
|
|
||||||
else:
|
|
||||||
return 13
|
|
||||||
|
|
||||||
|
|
||||||
class VBRIHeaderError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class VBRIHeader(object):
|
|
||||||
|
|
||||||
version = 0
|
|
||||||
"""VBRI header version"""
|
|
||||||
|
|
||||||
quality = 0
|
|
||||||
"""Quality indicator"""
|
|
||||||
|
|
||||||
bytes = 0
|
|
||||||
"""Number of bytes"""
|
|
||||||
|
|
||||||
frames = 0
|
|
||||||
"""Number of frames"""
|
|
||||||
|
|
||||||
toc_scale_factor = 0
|
|
||||||
"""Scale factor of TOC entries"""
|
|
||||||
|
|
||||||
toc_frames = 0
|
|
||||||
"""Number of frames per table entry"""
|
|
||||||
|
|
||||||
toc = []
|
|
||||||
"""TOC"""
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
"""Reads the VBRI header or raises VBRIHeaderError.
|
|
||||||
|
|
||||||
The file position is undefined after this returns
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = fileobj.read(26)
|
|
||||||
if len(data) != 26 or not data.startswith(b"VBRI"):
|
|
||||||
raise VBRIHeaderError("Not a VBRI header")
|
|
||||||
|
|
||||||
offset = 4
|
|
||||||
self.version, offset = cdata.uint16_be_from(data, offset)
|
|
||||||
if self.version != 1:
|
|
||||||
raise VBRIHeaderError(
|
|
||||||
"Unsupported header version: %r" % self.version)
|
|
||||||
|
|
||||||
offset += 2 # float16.. can't do
|
|
||||||
self.quality, offset = cdata.uint16_be_from(data, offset)
|
|
||||||
self.bytes, offset = cdata.uint32_be_from(data, offset)
|
|
||||||
self.frames, offset = cdata.uint32_be_from(data, offset)
|
|
||||||
|
|
||||||
toc_num_entries, offset = cdata.uint16_be_from(data, offset)
|
|
||||||
self.toc_scale_factor, offset = cdata.uint16_be_from(data, offset)
|
|
||||||
toc_entry_size, offset = cdata.uint16_be_from(data, offset)
|
|
||||||
self.toc_frames, offset = cdata.uint16_be_from(data, offset)
|
|
||||||
toc_size = toc_entry_size * toc_num_entries
|
|
||||||
toc_data = fileobj.read(toc_size)
|
|
||||||
if len(toc_data) != toc_size:
|
|
||||||
raise VBRIHeaderError("VBRI header truncated")
|
|
||||||
|
|
||||||
self.toc = []
|
|
||||||
if toc_entry_size == 2:
|
|
||||||
unpack = partial(cdata.uint16_be_from, toc_data)
|
|
||||||
elif toc_entry_size == 4:
|
|
||||||
unpack = partial(cdata.uint32_be_from, toc_data)
|
|
||||||
else:
|
|
||||||
raise VBRIHeaderError("Invalid TOC entry size")
|
|
||||||
|
|
||||||
self.toc = [unpack(i)[0] for i in xrange(0, toc_size, toc_entry_size)]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_offset(cls, info):
|
|
||||||
"""Offset in bytes from the start of the MPEG header including sync"""
|
|
||||||
|
|
||||||
assert info.layer == 3
|
|
||||||
|
|
||||||
return 36
|
|
|
@ -1,101 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright (C) 2005 Michael Urman
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of version 2 of the GNU General Public License as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
|
|
||||||
class PaddingInfo(object):
|
|
||||||
"""Abstract padding information object.
|
|
||||||
|
|
||||||
This will be passed to the callback function that can be used
|
|
||||||
for saving tags.
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
def my_callback(info: PaddingInfo):
|
|
||||||
return info.get_default_padding()
|
|
||||||
|
|
||||||
The callback should return the amount of padding to use (>= 0) based on
|
|
||||||
the content size and the padding of the file after saving. The actual used
|
|
||||||
amount of padding might vary depending on the file format (due to
|
|
||||||
alignment etc.)
|
|
||||||
|
|
||||||
The default implementation can be accessed using the
|
|
||||||
:meth:`get_default_padding` method in the callback.
|
|
||||||
"""
|
|
||||||
|
|
||||||
padding = 0
|
|
||||||
"""The amount of padding left after saving in bytes (can be negative if
|
|
||||||
more data needs to be added as padding is available)
|
|
||||||
"""
|
|
||||||
|
|
||||||
size = 0
|
|
||||||
"""The amount of data following the padding"""
|
|
||||||
|
|
||||||
def __init__(self, padding, size):
|
|
||||||
self.padding = padding
|
|
||||||
self.size = size
|
|
||||||
|
|
||||||
def get_default_padding(self):
|
|
||||||
"""The default implementation which tries to select a reasonable
|
|
||||||
amount of padding and which might change in future versions.
|
|
||||||
|
|
||||||
:return: Amount of padding after saving
|
|
||||||
:rtype: int
|
|
||||||
"""
|
|
||||||
|
|
||||||
high = 1024 * 10 + self.size // 100 # 10 KiB + 1% of trailing data
|
|
||||||
low = 1024 + self.size // 1000 # 1 KiB + 0.1% of trailing data
|
|
||||||
|
|
||||||
if self.padding >= 0:
|
|
||||||
# enough padding left
|
|
||||||
if self.padding > high:
|
|
||||||
# padding too large, reduce
|
|
||||||
return low
|
|
||||||
# just use existing padding as is
|
|
||||||
return self.padding
|
|
||||||
else:
|
|
||||||
# not enough padding, add some
|
|
||||||
return low
|
|
||||||
|
|
||||||
def _get_padding(self, user_func):
|
|
||||||
if user_func is None:
|
|
||||||
return self.get_default_padding()
|
|
||||||
else:
|
|
||||||
return user_func(self)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return "<%s size=%d padding=%d>" % (
|
|
||||||
type(self).__name__, self.size, self.padding)
|
|
||||||
|
|
||||||
|
|
||||||
class Metadata(object):
|
|
||||||
"""An abstract dict-like object.
|
|
||||||
|
|
||||||
Metadata is the base class for many of the tag objects in Mutagen.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__module__ = "mutagen"
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
if args or kwargs:
|
|
||||||
self.load(*args, **kwargs)
|
|
||||||
|
|
||||||
def load(self, *args, **kwargs):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def save(self, filename=None):
|
|
||||||
"""Save changes to a file."""
|
|
||||||
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def delete(self, filename=None):
|
|
||||||
"""Remove tags from a file.
|
|
||||||
|
|
||||||
In most cases this means any traces of the tag will be removed
|
|
||||||
from the file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
raise NotImplementedError
|
|
|
@ -1,231 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright 2015 Christoph Reiter
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import signal
|
|
||||||
import locale
|
|
||||||
import contextlib
|
|
||||||
import optparse
|
|
||||||
import ctypes
|
|
||||||
|
|
||||||
from ._compat import text_type, PY2, PY3, iterbytes
|
|
||||||
|
|
||||||
|
|
||||||
def split_escape(string, sep, maxsplit=None, escape_char="\\"):
|
|
||||||
"""Like unicode/str/bytes.split but allows for the separator to be escaped
|
|
||||||
|
|
||||||
If passed unicode/str/bytes will only return list of unicode/str/bytes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
assert len(sep) == 1
|
|
||||||
assert len(escape_char) == 1
|
|
||||||
|
|
||||||
if isinstance(string, bytes):
|
|
||||||
if isinstance(escape_char, text_type):
|
|
||||||
escape_char = escape_char.encode("ascii")
|
|
||||||
iter_ = iterbytes
|
|
||||||
else:
|
|
||||||
iter_ = iter
|
|
||||||
|
|
||||||
if maxsplit is None:
|
|
||||||
maxsplit = len(string)
|
|
||||||
|
|
||||||
empty = string[:0]
|
|
||||||
result = []
|
|
||||||
current = empty
|
|
||||||
escaped = False
|
|
||||||
for char in iter_(string):
|
|
||||||
if escaped:
|
|
||||||
if char != escape_char and char != sep:
|
|
||||||
current += escape_char
|
|
||||||
current += char
|
|
||||||
escaped = False
|
|
||||||
else:
|
|
||||||
if char == escape_char:
|
|
||||||
escaped = True
|
|
||||||
elif char == sep and len(result) < maxsplit:
|
|
||||||
result.append(current)
|
|
||||||
current = empty
|
|
||||||
else:
|
|
||||||
current += char
|
|
||||||
result.append(current)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class SignalHandler(object):
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._interrupted = False
|
|
||||||
self._nosig = False
|
|
||||||
self._init = False
|
|
||||||
|
|
||||||
def init(self):
|
|
||||||
signal.signal(signal.SIGINT, self._handler)
|
|
||||||
signal.signal(signal.SIGTERM, self._handler)
|
|
||||||
if os.name != "nt":
|
|
||||||
signal.signal(signal.SIGHUP, self._handler)
|
|
||||||
|
|
||||||
def _handler(self, signum, frame):
|
|
||||||
self._interrupted = True
|
|
||||||
if not self._nosig:
|
|
||||||
raise SystemExit("Aborted...")
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
|
||||||
def block(self):
|
|
||||||
"""While this context manager is active any signals for aborting
|
|
||||||
the process will be queued and exit the program once the context
|
|
||||||
is left.
|
|
||||||
"""
|
|
||||||
|
|
||||||
self._nosig = True
|
|
||||||
yield
|
|
||||||
self._nosig = False
|
|
||||||
if self._interrupted:
|
|
||||||
raise SystemExit("Aborted...")
|
|
||||||
|
|
||||||
|
|
||||||
def get_win32_unicode_argv():
|
|
||||||
"""Returns a unicode argv under Windows and standard sys.argv otherwise"""
|
|
||||||
|
|
||||||
if os.name != "nt" or not PY2:
|
|
||||||
return sys.argv
|
|
||||||
|
|
||||||
import ctypes
|
|
||||||
from ctypes import cdll, windll, wintypes
|
|
||||||
|
|
||||||
GetCommandLineW = cdll.kernel32.GetCommandLineW
|
|
||||||
GetCommandLineW.argtypes = []
|
|
||||||
GetCommandLineW.restype = wintypes.LPCWSTR
|
|
||||||
|
|
||||||
CommandLineToArgvW = windll.shell32.CommandLineToArgvW
|
|
||||||
CommandLineToArgvW.argtypes = [
|
|
||||||
wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)]
|
|
||||||
CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR)
|
|
||||||
|
|
||||||
LocalFree = windll.kernel32.LocalFree
|
|
||||||
LocalFree.argtypes = [wintypes.HLOCAL]
|
|
||||||
LocalFree.restype = wintypes.HLOCAL
|
|
||||||
|
|
||||||
argc = ctypes.c_int()
|
|
||||||
argv = CommandLineToArgvW(GetCommandLineW(), ctypes.byref(argc))
|
|
||||||
if not argv:
|
|
||||||
return
|
|
||||||
|
|
||||||
res = argv[max(0, argc.value - len(sys.argv)):argc.value]
|
|
||||||
|
|
||||||
LocalFree(argv)
|
|
||||||
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
def fsencoding():
|
|
||||||
"""The encoding used for paths, argv, environ, stdout and stdin"""
|
|
||||||
|
|
||||||
if os.name == "nt":
|
|
||||||
return ""
|
|
||||||
|
|
||||||
return locale.getpreferredencoding() or "utf-8"
|
|
||||||
|
|
||||||
|
|
||||||
def fsnative(text=u""):
|
|
||||||
"""Returns the passed text converted to the preferred path type
|
|
||||||
for each platform.
|
|
||||||
"""
|
|
||||||
|
|
||||||
assert isinstance(text, text_type)
|
|
||||||
|
|
||||||
if os.name == "nt" or PY3:
|
|
||||||
return text
|
|
||||||
else:
|
|
||||||
return text.encode(fsencoding(), "replace")
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def is_fsnative(arg):
|
|
||||||
"""If the passed value is of the preferred path type for each platform.
|
|
||||||
Note that on Python3+linux, paths can be bytes or str but this returns
|
|
||||||
False for bytes there.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if PY3 or os.name == "nt":
|
|
||||||
return isinstance(arg, text_type)
|
|
||||||
else:
|
|
||||||
return isinstance(arg, bytes)
|
|
||||||
|
|
||||||
|
|
||||||
def print_(*objects, **kwargs):
|
|
||||||
"""A print which supports bytes and str+surrogates under python3.
|
|
||||||
|
|
||||||
Needed so we can print anything passed to us through argv and environ.
|
|
||||||
Under Windows only text_type is allowed.
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
objects: one or more bytes/text
|
|
||||||
linesep (bool): whether a line separator should be appended
|
|
||||||
sep (bool): whether objects should be printed separated by spaces
|
|
||||||
"""
|
|
||||||
|
|
||||||
linesep = kwargs.pop("linesep", True)
|
|
||||||
sep = kwargs.pop("sep", True)
|
|
||||||
file_ = kwargs.pop("file", None)
|
|
||||||
if file_ is None:
|
|
||||||
file_ = sys.stdout
|
|
||||||
|
|
||||||
old_cp = None
|
|
||||||
if os.name == "nt":
|
|
||||||
# Try to force the output to cp65001 aka utf-8.
|
|
||||||
# If that fails use the current one (most likely cp850, so
|
|
||||||
# most of unicode will be replaced with '?')
|
|
||||||
encoding = "utf-8"
|
|
||||||
old_cp = ctypes.windll.kernel32.GetConsoleOutputCP()
|
|
||||||
if ctypes.windll.kernel32.SetConsoleOutputCP(65001) == 0:
|
|
||||||
encoding = getattr(sys.stdout, "encoding", None) or "utf-8"
|
|
||||||
old_cp = None
|
|
||||||
else:
|
|
||||||
encoding = fsencoding()
|
|
||||||
|
|
||||||
try:
|
|
||||||
if linesep:
|
|
||||||
objects = list(objects) + [os.linesep]
|
|
||||||
|
|
||||||
parts = []
|
|
||||||
for text in objects:
|
|
||||||
if isinstance(text, text_type):
|
|
||||||
if PY3:
|
|
||||||
try:
|
|
||||||
text = text.encode(encoding, 'surrogateescape')
|
|
||||||
except UnicodeEncodeError:
|
|
||||||
text = text.encode(encoding, 'replace')
|
|
||||||
else:
|
|
||||||
text = text.encode(encoding, 'replace')
|
|
||||||
parts.append(text)
|
|
||||||
|
|
||||||
data = (b" " if sep else b"").join(parts)
|
|
||||||
try:
|
|
||||||
fileno = file_.fileno()
|
|
||||||
except (AttributeError, OSError, ValueError):
|
|
||||||
# for tests when stdout is replaced
|
|
||||||
try:
|
|
||||||
file_.write(data)
|
|
||||||
except TypeError:
|
|
||||||
file_.write(data.decode(encoding, "replace"))
|
|
||||||
else:
|
|
||||||
file_.flush()
|
|
||||||
os.write(fileno, data)
|
|
||||||
finally:
|
|
||||||
# reset the code page to what we had before
|
|
||||||
if old_cp is not None:
|
|
||||||
ctypes.windll.kernel32.SetConsoleOutputCP(old_cp)
|
|
||||||
|
|
||||||
|
|
||||||
class OptionParser(optparse.OptionParser):
|
|
||||||
"""OptionParser subclass which supports printing Unicode under Windows"""
|
|
||||||
|
|
||||||
def print_help(self, file=None):
|
|
||||||
print_(self.format_help(), file=file)
|
|
|
@ -1,550 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2006 Joe Wreschnig
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""Utility classes for Mutagen.
|
|
||||||
|
|
||||||
You should not rely on the interfaces here being stable. They are
|
|
||||||
intended for internal use in Mutagen only.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import struct
|
|
||||||
import codecs
|
|
||||||
|
|
||||||
from fnmatch import fnmatchcase
|
|
||||||
|
|
||||||
from ._compat import chr_, PY2, iteritems, iterbytes, integer_types, xrange, \
|
|
||||||
izip
|
|
||||||
|
|
||||||
|
|
||||||
class MutagenError(Exception):
|
|
||||||
"""Base class for all custom exceptions in mutagen
|
|
||||||
|
|
||||||
.. versionadded:: 1.25
|
|
||||||
"""
|
|
||||||
|
|
||||||
__module__ = "mutagen"
|
|
||||||
|
|
||||||
|
|
||||||
def total_ordering(cls):
|
|
||||||
assert "__eq__" in cls.__dict__
|
|
||||||
assert "__lt__" in cls.__dict__
|
|
||||||
|
|
||||||
cls.__le__ = lambda self, other: self == other or self < other
|
|
||||||
cls.__gt__ = lambda self, other: not (self == other or self < other)
|
|
||||||
cls.__ge__ = lambda self, other: not self < other
|
|
||||||
cls.__ne__ = lambda self, other: not self.__eq__(other)
|
|
||||||
|
|
||||||
return cls
|
|
||||||
|
|
||||||
|
|
||||||
def hashable(cls):
|
|
||||||
"""Makes sure the class is hashable.
|
|
||||||
|
|
||||||
Needs a working __eq__ and __hash__ and will add a __ne__.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# py2
|
|
||||||
assert "__hash__" in cls.__dict__
|
|
||||||
# py3
|
|
||||||
assert cls.__dict__["__hash__"] is not None
|
|
||||||
assert "__eq__" in cls.__dict__
|
|
||||||
|
|
||||||
cls.__ne__ = lambda self, other: not self.__eq__(other)
|
|
||||||
|
|
||||||
return cls
|
|
||||||
|
|
||||||
|
|
||||||
def enum(cls):
|
|
||||||
assert cls.__bases__ == (object,)
|
|
||||||
|
|
||||||
d = dict(cls.__dict__)
|
|
||||||
new_type = type(cls.__name__, (int,), d)
|
|
||||||
new_type.__module__ = cls.__module__
|
|
||||||
|
|
||||||
map_ = {}
|
|
||||||
for key, value in iteritems(d):
|
|
||||||
if key.upper() == key and isinstance(value, integer_types):
|
|
||||||
value_instance = new_type(value)
|
|
||||||
setattr(new_type, key, value_instance)
|
|
||||||
map_[value] = key
|
|
||||||
|
|
||||||
def str_(self):
|
|
||||||
if self in map_:
|
|
||||||
return "%s.%s" % (type(self).__name__, map_[self])
|
|
||||||
return "%d" % int(self)
|
|
||||||
|
|
||||||
def repr_(self):
|
|
||||||
if self in map_:
|
|
||||||
return "<%s.%s: %d>" % (type(self).__name__, map_[self], int(self))
|
|
||||||
return "%d" % int(self)
|
|
||||||
|
|
||||||
setattr(new_type, "__repr__", repr_)
|
|
||||||
setattr(new_type, "__str__", str_)
|
|
||||||
|
|
||||||
return new_type
|
|
||||||
|
|
||||||
|
|
||||||
@total_ordering
|
|
||||||
class DictMixin(object):
|
|
||||||
"""Implement the dict API using keys() and __*item__ methods.
|
|
||||||
|
|
||||||
Similar to UserDict.DictMixin, this takes a class that defines
|
|
||||||
__getitem__, __setitem__, __delitem__, and keys(), and turns it
|
|
||||||
into a full dict-like object.
|
|
||||||
|
|
||||||
UserDict.DictMixin is not suitable for this purpose because it's
|
|
||||||
an old-style class.
|
|
||||||
|
|
||||||
This class is not optimized for very large dictionaries; many
|
|
||||||
functions have linear memory requirements. I recommend you
|
|
||||||
override some of these functions if speed is required.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
return iter(self.keys())
|
|
||||||
|
|
||||||
def __has_key(self, key):
|
|
||||||
try:
|
|
||||||
self[key]
|
|
||||||
except KeyError:
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if PY2:
|
|
||||||
has_key = __has_key
|
|
||||||
|
|
||||||
__contains__ = __has_key
|
|
||||||
|
|
||||||
if PY2:
|
|
||||||
iterkeys = lambda self: iter(self.keys())
|
|
||||||
|
|
||||||
def values(self):
|
|
||||||
return [self[k] for k in self.keys()]
|
|
||||||
|
|
||||||
if PY2:
|
|
||||||
itervalues = lambda self: iter(self.values())
|
|
||||||
|
|
||||||
def items(self):
|
|
||||||
return list(izip(self.keys(), self.values()))
|
|
||||||
|
|
||||||
if PY2:
|
|
||||||
iteritems = lambda s: iter(s.items())
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
for key in list(self.keys()):
|
|
||||||
self.__delitem__(key)
|
|
||||||
|
|
||||||
def pop(self, key, *args):
|
|
||||||
if len(args) > 1:
|
|
||||||
raise TypeError("pop takes at most two arguments")
|
|
||||||
try:
|
|
||||||
value = self[key]
|
|
||||||
except KeyError:
|
|
||||||
if args:
|
|
||||||
return args[0]
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
del(self[key])
|
|
||||||
return value
|
|
||||||
|
|
||||||
def popitem(self):
|
|
||||||
for key in self.keys():
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise KeyError("dictionary is empty")
|
|
||||||
return key, self.pop(key)
|
|
||||||
|
|
||||||
def update(self, other=None, **kwargs):
|
|
||||||
if other is None:
|
|
||||||
self.update(kwargs)
|
|
||||||
other = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
for key, value in other.items():
|
|
||||||
self.__setitem__(key, value)
|
|
||||||
except AttributeError:
|
|
||||||
for key, value in other:
|
|
||||||
self[key] = value
|
|
||||||
|
|
||||||
def setdefault(self, key, default=None):
|
|
||||||
try:
|
|
||||||
return self[key]
|
|
||||||
except KeyError:
|
|
||||||
self[key] = default
|
|
||||||
return default
|
|
||||||
|
|
||||||
def get(self, key, default=None):
|
|
||||||
try:
|
|
||||||
return self[key]
|
|
||||||
except KeyError:
|
|
||||||
return default
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return repr(dict(self.items()))
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
return dict(self.items()) == other
|
|
||||||
|
|
||||||
def __lt__(self, other):
|
|
||||||
return dict(self.items()) < other
|
|
||||||
|
|
||||||
__hash__ = object.__hash__
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
return len(self.keys())
|
|
||||||
|
|
||||||
|
|
||||||
class DictProxy(DictMixin):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
self.__dict = {}
|
|
||||||
super(DictProxy, self).__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
return self.__dict[key]
|
|
||||||
|
|
||||||
def __setitem__(self, key, value):
|
|
||||||
self.__dict[key] = value
|
|
||||||
|
|
||||||
def __delitem__(self, key):
|
|
||||||
del(self.__dict[key])
|
|
||||||
|
|
||||||
def keys(self):
|
|
||||||
return self.__dict.keys()
|
|
||||||
|
|
||||||
|
|
||||||
def _fill_cdata(cls):
|
|
||||||
"""Add struct pack/unpack functions"""
|
|
||||||
|
|
||||||
funcs = {}
|
|
||||||
for key, name in [("b", "char"), ("h", "short"),
|
|
||||||
("i", "int"), ("q", "longlong")]:
|
|
||||||
for echar, esuffix in [("<", "le"), (">", "be")]:
|
|
||||||
esuffix = "_" + esuffix
|
|
||||||
for unsigned in [True, False]:
|
|
||||||
s = struct.Struct(echar + (key.upper() if unsigned else key))
|
|
||||||
get_wrapper = lambda f: lambda *a, **k: f(*a, **k)[0]
|
|
||||||
unpack = get_wrapper(s.unpack)
|
|
||||||
unpack_from = get_wrapper(s.unpack_from)
|
|
||||||
|
|
||||||
def get_unpack_from(s):
|
|
||||||
def unpack_from(data, offset=0):
|
|
||||||
return s.unpack_from(data, offset)[0], offset + s.size
|
|
||||||
return unpack_from
|
|
||||||
|
|
||||||
unpack_from = get_unpack_from(s)
|
|
||||||
pack = s.pack
|
|
||||||
|
|
||||||
prefix = "u" if unsigned else ""
|
|
||||||
if s.size == 1:
|
|
||||||
esuffix = ""
|
|
||||||
bits = str(s.size * 8)
|
|
||||||
funcs["%s%s%s" % (prefix, name, esuffix)] = unpack
|
|
||||||
funcs["%sint%s%s" % (prefix, bits, esuffix)] = unpack
|
|
||||||
funcs["%s%s%s_from" % (prefix, name, esuffix)] = unpack_from
|
|
||||||
funcs["%sint%s%s_from" % (prefix, bits, esuffix)] = unpack_from
|
|
||||||
funcs["to_%s%s%s" % (prefix, name, esuffix)] = pack
|
|
||||||
funcs["to_%sint%s%s" % (prefix, bits, esuffix)] = pack
|
|
||||||
|
|
||||||
for key, func in iteritems(funcs):
|
|
||||||
setattr(cls, key, staticmethod(func))
|
|
||||||
|
|
||||||
|
|
||||||
class cdata(object):
|
|
||||||
"""C character buffer to Python numeric type conversions.
|
|
||||||
|
|
||||||
For each size/sign/endianness:
|
|
||||||
uint32_le(data)/to_uint32_le(num)/uint32_le_from(data, offset=0)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from struct import error
|
|
||||||
error = error
|
|
||||||
|
|
||||||
bitswap = b''.join(
|
|
||||||
chr_(sum(((val >> i) & 1) << (7 - i) for i in xrange(8)))
|
|
||||||
for val in xrange(256))
|
|
||||||
|
|
||||||
test_bit = staticmethod(lambda value, n: bool((value >> n) & 1))
|
|
||||||
|
|
||||||
|
|
||||||
_fill_cdata(cdata)
|
|
||||||
|
|
||||||
|
|
||||||
def get_size(fileobj):
|
|
||||||
"""Returns the size of the file object. The position when passed in will
|
|
||||||
be preserved if no error occurs.
|
|
||||||
|
|
||||||
In case of an error raises IOError.
|
|
||||||
"""
|
|
||||||
|
|
||||||
old_pos = fileobj.tell()
|
|
||||||
try:
|
|
||||||
fileobj.seek(0, 2)
|
|
||||||
return fileobj.tell()
|
|
||||||
finally:
|
|
||||||
fileobj.seek(old_pos, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def insert_bytes(fobj, size, offset, BUFFER_SIZE=2 ** 16):
|
|
||||||
"""Insert size bytes of empty space starting at offset.
|
|
||||||
|
|
||||||
fobj must be an open file object, open rb+ or
|
|
||||||
equivalent. Mutagen tries to use mmap to resize the file, but
|
|
||||||
falls back to a significantly slower method if mmap fails.
|
|
||||||
"""
|
|
||||||
|
|
||||||
assert 0 < size
|
|
||||||
assert 0 <= offset
|
|
||||||
|
|
||||||
fobj.seek(0, 2)
|
|
||||||
filesize = fobj.tell()
|
|
||||||
movesize = filesize - offset
|
|
||||||
fobj.write(b'\x00' * size)
|
|
||||||
fobj.flush()
|
|
||||||
|
|
||||||
try:
|
|
||||||
import mmap
|
|
||||||
file_map = mmap.mmap(fobj.fileno(), filesize + size)
|
|
||||||
try:
|
|
||||||
file_map.move(offset + size, offset, movesize)
|
|
||||||
finally:
|
|
||||||
file_map.close()
|
|
||||||
except (ValueError, EnvironmentError, ImportError, AttributeError):
|
|
||||||
# handle broken mmap scenarios, BytesIO()
|
|
||||||
fobj.truncate(filesize)
|
|
||||||
|
|
||||||
fobj.seek(0, 2)
|
|
||||||
padsize = size
|
|
||||||
# Don't generate an enormous string if we need to pad
|
|
||||||
# the file out several megs.
|
|
||||||
while padsize:
|
|
||||||
addsize = min(BUFFER_SIZE, padsize)
|
|
||||||
fobj.write(b"\x00" * addsize)
|
|
||||||
padsize -= addsize
|
|
||||||
|
|
||||||
fobj.seek(filesize, 0)
|
|
||||||
while movesize:
|
|
||||||
# At the start of this loop, fobj is pointing at the end
|
|
||||||
# of the data we need to move, which is of movesize length.
|
|
||||||
thismove = min(BUFFER_SIZE, movesize)
|
|
||||||
# Seek back however much we're going to read this frame.
|
|
||||||
fobj.seek(-thismove, 1)
|
|
||||||
nextpos = fobj.tell()
|
|
||||||
# Read it, so we're back at the end.
|
|
||||||
data = fobj.read(thismove)
|
|
||||||
# Seek back to where we need to write it.
|
|
||||||
fobj.seek(-thismove + size, 1)
|
|
||||||
# Write it.
|
|
||||||
fobj.write(data)
|
|
||||||
# And seek back to the end of the unmoved data.
|
|
||||||
fobj.seek(nextpos)
|
|
||||||
movesize -= thismove
|
|
||||||
|
|
||||||
fobj.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def delete_bytes(fobj, size, offset, BUFFER_SIZE=2 ** 16):
|
|
||||||
"""Delete size bytes of empty space starting at offset.
|
|
||||||
|
|
||||||
fobj must be an open file object, open rb+ or
|
|
||||||
equivalent. Mutagen tries to use mmap to resize the file, but
|
|
||||||
falls back to a significantly slower method if mmap fails.
|
|
||||||
"""
|
|
||||||
|
|
||||||
assert 0 < size
|
|
||||||
assert 0 <= offset
|
|
||||||
|
|
||||||
fobj.seek(0, 2)
|
|
||||||
filesize = fobj.tell()
|
|
||||||
movesize = filesize - offset - size
|
|
||||||
assert 0 <= movesize
|
|
||||||
|
|
||||||
if movesize > 0:
|
|
||||||
fobj.flush()
|
|
||||||
try:
|
|
||||||
import mmap
|
|
||||||
file_map = mmap.mmap(fobj.fileno(), filesize)
|
|
||||||
try:
|
|
||||||
file_map.move(offset, offset + size, movesize)
|
|
||||||
finally:
|
|
||||||
file_map.close()
|
|
||||||
except (ValueError, EnvironmentError, ImportError, AttributeError):
|
|
||||||
# handle broken mmap scenarios, BytesIO()
|
|
||||||
fobj.seek(offset + size)
|
|
||||||
buf = fobj.read(BUFFER_SIZE)
|
|
||||||
while buf:
|
|
||||||
fobj.seek(offset)
|
|
||||||
fobj.write(buf)
|
|
||||||
offset += len(buf)
|
|
||||||
fobj.seek(offset + size)
|
|
||||||
buf = fobj.read(BUFFER_SIZE)
|
|
||||||
fobj.truncate(filesize - size)
|
|
||||||
fobj.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def resize_bytes(fobj, old_size, new_size, offset):
|
|
||||||
"""Resize an area in a file adding and deleting at the end of it.
|
|
||||||
Does nothing if no resizing is needed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if new_size < old_size:
|
|
||||||
delete_size = old_size - new_size
|
|
||||||
delete_at = offset + new_size
|
|
||||||
delete_bytes(fobj, delete_size, delete_at)
|
|
||||||
elif new_size > old_size:
|
|
||||||
insert_size = new_size - old_size
|
|
||||||
insert_at = offset + old_size
|
|
||||||
insert_bytes(fobj, insert_size, insert_at)
|
|
||||||
|
|
||||||
|
|
||||||
def dict_match(d, key, default=None):
|
|
||||||
"""Like __getitem__ but works as if the keys() are all filename patterns.
|
|
||||||
Returns the value of any dict key that matches the passed key.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if key in d and "[" not in key:
|
|
||||||
return d[key]
|
|
||||||
else:
|
|
||||||
for pattern, value in iteritems(d):
|
|
||||||
if fnmatchcase(key, pattern):
|
|
||||||
return value
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def decode_terminated(data, encoding, strict=True):
|
|
||||||
"""Returns the decoded data until the first NULL terminator
|
|
||||||
and all data after it.
|
|
||||||
|
|
||||||
In case the data can't be decoded raises UnicodeError.
|
|
||||||
In case the encoding is not found raises LookupError.
|
|
||||||
In case the data isn't null terminated (even if it is encoded correctly)
|
|
||||||
raises ValueError except if strict is False, then the decoded string
|
|
||||||
will be returned anyway.
|
|
||||||
"""
|
|
||||||
|
|
||||||
codec_info = codecs.lookup(encoding)
|
|
||||||
|
|
||||||
# normalize encoding name so we can compare by name
|
|
||||||
encoding = codec_info.name
|
|
||||||
|
|
||||||
# fast path
|
|
||||||
if encoding in ("utf-8", "iso8859-1"):
|
|
||||||
index = data.find(b"\x00")
|
|
||||||
if index == -1:
|
|
||||||
# make sure we raise UnicodeError first, like in the slow path
|
|
||||||
res = data.decode(encoding), b""
|
|
||||||
if strict:
|
|
||||||
raise ValueError("not null terminated")
|
|
||||||
else:
|
|
||||||
return res
|
|
||||||
return data[:index].decode(encoding), data[index + 1:]
|
|
||||||
|
|
||||||
# slow path
|
|
||||||
decoder = codec_info.incrementaldecoder()
|
|
||||||
r = []
|
|
||||||
for i, b in enumerate(iterbytes(data)):
|
|
||||||
c = decoder.decode(b)
|
|
||||||
if c == u"\x00":
|
|
||||||
return u"".join(r), data[i + 1:]
|
|
||||||
r.append(c)
|
|
||||||
else:
|
|
||||||
# make sure the decoder is finished
|
|
||||||
r.append(decoder.decode(b"", True))
|
|
||||||
if strict:
|
|
||||||
raise ValueError("not null terminated")
|
|
||||||
return u"".join(r), b""
|
|
||||||
|
|
||||||
|
|
||||||
class BitReaderError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class BitReader(object):
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
self._fileobj = fileobj
|
|
||||||
self._buffer = 0
|
|
||||||
self._bits = 0
|
|
||||||
self._pos = fileobj.tell()
|
|
||||||
|
|
||||||
def bits(self, count):
|
|
||||||
"""Reads `count` bits and returns an uint, MSB read first.
|
|
||||||
|
|
||||||
May raise BitReaderError if not enough data could be read or
|
|
||||||
IOError by the underlying file object.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if count < 0:
|
|
||||||
raise ValueError
|
|
||||||
|
|
||||||
if count > self._bits:
|
|
||||||
n_bytes = (count - self._bits + 7) // 8
|
|
||||||
data = self._fileobj.read(n_bytes)
|
|
||||||
if len(data) != n_bytes:
|
|
||||||
raise BitReaderError("not enough data")
|
|
||||||
for b in bytearray(data):
|
|
||||||
self._buffer = (self._buffer << 8) | b
|
|
||||||
self._bits += n_bytes * 8
|
|
||||||
|
|
||||||
self._bits -= count
|
|
||||||
value = self._buffer >> self._bits
|
|
||||||
self._buffer &= (1 << self._bits) - 1
|
|
||||||
assert self._bits < 8
|
|
||||||
return value
|
|
||||||
|
|
||||||
def bytes(self, count):
|
|
||||||
"""Returns a bytearray of length `count`. Works unaligned."""
|
|
||||||
|
|
||||||
if count < 0:
|
|
||||||
raise ValueError
|
|
||||||
|
|
||||||
# fast path
|
|
||||||
if self._bits == 0:
|
|
||||||
data = self._fileobj.read(count)
|
|
||||||
if len(data) != count:
|
|
||||||
raise BitReaderError("not enough data")
|
|
||||||
return data
|
|
||||||
|
|
||||||
return bytes(bytearray(self.bits(8) for _ in xrange(count)))
|
|
||||||
|
|
||||||
def skip(self, count):
|
|
||||||
"""Skip `count` bits.
|
|
||||||
|
|
||||||
Might raise BitReaderError if there wasn't enough data to skip,
|
|
||||||
but might also fail on the next bits() instead.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if count < 0:
|
|
||||||
raise ValueError
|
|
||||||
|
|
||||||
if count <= self._bits:
|
|
||||||
self.bits(count)
|
|
||||||
else:
|
|
||||||
count -= self.align()
|
|
||||||
n_bytes = count // 8
|
|
||||||
self._fileobj.seek(n_bytes, 1)
|
|
||||||
count -= n_bytes * 8
|
|
||||||
self.bits(count)
|
|
||||||
|
|
||||||
def get_position(self):
|
|
||||||
"""Returns the amount of bits read or skipped so far"""
|
|
||||||
|
|
||||||
return (self._fileobj.tell() - self._pos) * 8 - self._bits
|
|
||||||
|
|
||||||
def align(self):
|
|
||||||
"""Align to the next byte, returns the amount of bits skipped"""
|
|
||||||
|
|
||||||
bits = self._bits
|
|
||||||
self._buffer = 0
|
|
||||||
self._bits = 0
|
|
||||||
return bits
|
|
||||||
|
|
||||||
def is_aligned(self):
|
|
||||||
"""If we are currently aligned to bytes and nothing is buffered"""
|
|
||||||
|
|
||||||
return self._bits == 0
|
|
|
@ -1,330 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2005-2006 Joe Wreschnig
|
|
||||||
# 2013 Christoph Reiter
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of version 2 of the GNU General Public License as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""Read and write Vorbis comment data.
|
|
||||||
|
|
||||||
Vorbis comments are freeform key/value pairs; keys are
|
|
||||||
case-insensitive ASCII and values are Unicode strings. A key may have
|
|
||||||
multiple values.
|
|
||||||
|
|
||||||
The specification is at http://www.xiph.org/vorbis/doc/v-comment.html.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import mutagen
|
|
||||||
from ._compat import reraise, BytesIO, text_type, xrange, PY3, PY2
|
|
||||||
from mutagen._util import DictMixin, cdata
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_key(key):
|
|
||||||
"""Return true if a string is a valid Vorbis comment key.
|
|
||||||
|
|
||||||
Valid Vorbis comment keys are printable ASCII between 0x20 (space)
|
|
||||||
and 0x7D ('}'), excluding '='.
|
|
||||||
|
|
||||||
Takes str/unicode in Python 2, unicode in Python 3
|
|
||||||
"""
|
|
||||||
|
|
||||||
if PY3 and isinstance(key, bytes):
|
|
||||||
raise TypeError("needs to be str not bytes")
|
|
||||||
|
|
||||||
for c in key:
|
|
||||||
if c < " " or c > "}" or c == "=":
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return bool(key)
|
|
||||||
|
|
||||||
|
|
||||||
istag = is_valid_key
|
|
||||||
|
|
||||||
|
|
||||||
class error(IOError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class VorbisUnsetFrameError(error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class VorbisEncodingError(error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class VComment(mutagen.Metadata, list):
|
|
||||||
"""A Vorbis comment parser, accessor, and renderer.
|
|
||||||
|
|
||||||
All comment ordering is preserved. A VComment is a list of
|
|
||||||
key/value pairs, and so any Python list method can be used on it.
|
|
||||||
|
|
||||||
Vorbis comments are always wrapped in something like an Ogg Vorbis
|
|
||||||
bitstream or a FLAC metadata block, so this loads string data or a
|
|
||||||
file-like object, not a filename.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
* vendor -- the stream 'vendor' (i.e. writer); default 'Mutagen'
|
|
||||||
"""
|
|
||||||
|
|
||||||
vendor = u"Mutagen " + mutagen.version_string
|
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
|
||||||
self._size = 0
|
|
||||||
# Collect the args to pass to load, this lets child classes
|
|
||||||
# override just load and get equivalent magic for the
|
|
||||||
# constructor.
|
|
||||||
if data is not None:
|
|
||||||
if isinstance(data, bytes):
|
|
||||||
data = BytesIO(data)
|
|
||||||
elif not hasattr(data, 'read'):
|
|
||||||
raise TypeError("VComment requires bytes or a file-like")
|
|
||||||
start = data.tell()
|
|
||||||
self.load(data, *args, **kwargs)
|
|
||||||
self._size = data.tell() - start
|
|
||||||
|
|
||||||
def load(self, fileobj, errors='replace', framing=True):
|
|
||||||
"""Parse a Vorbis comment from a file-like object.
|
|
||||||
|
|
||||||
Keyword arguments:
|
|
||||||
|
|
||||||
* errors:
|
|
||||||
'strict', 'replace', or 'ignore'. This affects Unicode decoding
|
|
||||||
and how other malformed content is interpreted.
|
|
||||||
* framing -- if true, fail if a framing bit is not present
|
|
||||||
|
|
||||||
Framing bits are required by the Vorbis comment specification,
|
|
||||||
but are not used in FLAC Vorbis comment blocks.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
vendor_length = cdata.uint_le(fileobj.read(4))
|
|
||||||
self.vendor = fileobj.read(vendor_length).decode('utf-8', errors)
|
|
||||||
count = cdata.uint_le(fileobj.read(4))
|
|
||||||
for i in xrange(count):
|
|
||||||
length = cdata.uint_le(fileobj.read(4))
|
|
||||||
try:
|
|
||||||
string = fileobj.read(length).decode('utf-8', errors)
|
|
||||||
except (OverflowError, MemoryError):
|
|
||||||
raise error("cannot read %d bytes, too large" % length)
|
|
||||||
try:
|
|
||||||
tag, value = string.split('=', 1)
|
|
||||||
except ValueError as err:
|
|
||||||
if errors == "ignore":
|
|
||||||
continue
|
|
||||||
elif errors == "replace":
|
|
||||||
tag, value = u"unknown%d" % i, string
|
|
||||||
else:
|
|
||||||
reraise(VorbisEncodingError, err, sys.exc_info()[2])
|
|
||||||
try:
|
|
||||||
tag = tag.encode('ascii', errors)
|
|
||||||
except UnicodeEncodeError:
|
|
||||||
raise VorbisEncodingError("invalid tag name %r" % tag)
|
|
||||||
else:
|
|
||||||
# string keys in py3k
|
|
||||||
if PY3:
|
|
||||||
tag = tag.decode("ascii")
|
|
||||||
if is_valid_key(tag):
|
|
||||||
self.append((tag, value))
|
|
||||||
|
|
||||||
if framing and not bytearray(fileobj.read(1))[0] & 0x01:
|
|
||||||
raise VorbisUnsetFrameError("framing bit was unset")
|
|
||||||
except (cdata.error, TypeError):
|
|
||||||
raise error("file is not a valid Vorbis comment")
|
|
||||||
|
|
||||||
def validate(self):
|
|
||||||
"""Validate keys and values.
|
|
||||||
|
|
||||||
Check to make sure every key used is a valid Vorbis key, and
|
|
||||||
that every value used is a valid Unicode or UTF-8 string. If
|
|
||||||
any invalid keys or values are found, a ValueError is raised.
|
|
||||||
|
|
||||||
In Python 3 all keys and values have to be a string.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not isinstance(self.vendor, text_type):
|
|
||||||
if PY3:
|
|
||||||
raise ValueError("vendor needs to be str")
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.vendor.decode('utf-8')
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
raise ValueError
|
|
||||||
|
|
||||||
for key, value in self:
|
|
||||||
try:
|
|
||||||
if not is_valid_key(key):
|
|
||||||
raise ValueError
|
|
||||||
except TypeError:
|
|
||||||
raise ValueError("%r is not a valid key" % key)
|
|
||||||
|
|
||||||
if not isinstance(value, text_type):
|
|
||||||
if PY3:
|
|
||||||
raise ValueError("%r needs to be str" % key)
|
|
||||||
|
|
||||||
try:
|
|
||||||
value.decode("utf-8")
|
|
||||||
except:
|
|
||||||
raise ValueError("%r is not a valid value" % value)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
"""Clear all keys from the comment."""
|
|
||||||
|
|
||||||
for i in list(self):
|
|
||||||
self.remove(i)
|
|
||||||
|
|
||||||
def write(self, framing=True):
|
|
||||||
"""Return a string representation of the data.
|
|
||||||
|
|
||||||
Validation is always performed, so calling this function on
|
|
||||||
invalid data may raise a ValueError.
|
|
||||||
|
|
||||||
Keyword arguments:
|
|
||||||
|
|
||||||
* framing -- if true, append a framing bit (see load)
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.validate()
|
|
||||||
|
|
||||||
def _encode(value):
|
|
||||||
if not isinstance(value, bytes):
|
|
||||||
return value.encode('utf-8')
|
|
||||||
return value
|
|
||||||
|
|
||||||
f = BytesIO()
|
|
||||||
vendor = _encode(self.vendor)
|
|
||||||
f.write(cdata.to_uint_le(len(vendor)))
|
|
||||||
f.write(vendor)
|
|
||||||
f.write(cdata.to_uint_le(len(self)))
|
|
||||||
for tag, value in self:
|
|
||||||
tag = _encode(tag)
|
|
||||||
value = _encode(value)
|
|
||||||
comment = tag + b"=" + value
|
|
||||||
f.write(cdata.to_uint_le(len(comment)))
|
|
||||||
f.write(comment)
|
|
||||||
if framing:
|
|
||||||
f.write(b"\x01")
|
|
||||||
return f.getvalue()
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
|
|
||||||
def _decode(value):
|
|
||||||
if not isinstance(value, text_type):
|
|
||||||
return value.decode('utf-8', 'replace')
|
|
||||||
return value
|
|
||||||
|
|
||||||
tags = [u"%s=%s" % (_decode(k), _decode(v)) for k, v in self]
|
|
||||||
return u"\n".join(tags)
|
|
||||||
|
|
||||||
|
|
||||||
class VCommentDict(VComment, DictMixin):
|
|
||||||
"""A VComment that looks like a dictionary.
|
|
||||||
|
|
||||||
This object differs from a dictionary in two ways. First,
|
|
||||||
len(comment) will still return the number of values, not the
|
|
||||||
number of keys. Secondly, iterating through the object will
|
|
||||||
iterate over (key, value) pairs, not keys. Since a key may have
|
|
||||||
multiple values, the same value may appear multiple times while
|
|
||||||
iterating.
|
|
||||||
|
|
||||||
Since Vorbis comment keys are case-insensitive, all keys are
|
|
||||||
normalized to lowercase ASCII.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
"""A list of values for the key.
|
|
||||||
|
|
||||||
This is a copy, so comment['title'].append('a title') will not
|
|
||||||
work.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# PY3 only
|
|
||||||
if isinstance(key, slice):
|
|
||||||
return VComment.__getitem__(self, key)
|
|
||||||
|
|
||||||
if not is_valid_key(key):
|
|
||||||
raise ValueError
|
|
||||||
|
|
||||||
key = key.lower()
|
|
||||||
|
|
||||||
values = [value for (k, value) in self if k.lower() == key]
|
|
||||||
if not values:
|
|
||||||
raise KeyError(key)
|
|
||||||
else:
|
|
||||||
return values
|
|
||||||
|
|
||||||
def __delitem__(self, key):
|
|
||||||
"""Delete all values associated with the key."""
|
|
||||||
|
|
||||||
# PY3 only
|
|
||||||
if isinstance(key, slice):
|
|
||||||
return VComment.__delitem__(self, key)
|
|
||||||
|
|
||||||
if not is_valid_key(key):
|
|
||||||
raise ValueError
|
|
||||||
|
|
||||||
key = key.lower()
|
|
||||||
to_delete = [x for x in self if x[0].lower() == key]
|
|
||||||
if not to_delete:
|
|
||||||
raise KeyError(key)
|
|
||||||
else:
|
|
||||||
for item in to_delete:
|
|
||||||
self.remove(item)
|
|
||||||
|
|
||||||
def __contains__(self, key):
|
|
||||||
"""Return true if the key has any values."""
|
|
||||||
|
|
||||||
if not is_valid_key(key):
|
|
||||||
raise ValueError
|
|
||||||
|
|
||||||
key = key.lower()
|
|
||||||
for k, value in self:
|
|
||||||
if k.lower() == key:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def __setitem__(self, key, values):
|
|
||||||
"""Set a key's value or values.
|
|
||||||
|
|
||||||
Setting a value overwrites all old ones. The value may be a
|
|
||||||
list of Unicode or UTF-8 strings, or a single Unicode or UTF-8
|
|
||||||
string.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# PY3 only
|
|
||||||
if isinstance(key, slice):
|
|
||||||
return VComment.__setitem__(self, key, values)
|
|
||||||
|
|
||||||
if not is_valid_key(key):
|
|
||||||
raise ValueError
|
|
||||||
|
|
||||||
if not isinstance(values, list):
|
|
||||||
values = [values]
|
|
||||||
try:
|
|
||||||
del(self[key])
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if PY2:
|
|
||||||
key = key.encode('ascii')
|
|
||||||
|
|
||||||
for value in values:
|
|
||||||
self.append((key, value))
|
|
||||||
|
|
||||||
def keys(self):
|
|
||||||
"""Return all keys in the comment."""
|
|
||||||
|
|
||||||
return list(set([k.lower() for k, v in self]))
|
|
||||||
|
|
||||||
def as_dict(self):
|
|
||||||
"""Return a copy of the comment data in a real dict."""
|
|
||||||
|
|
||||||
return dict([(key, self[key]) for key in self.keys()])
|
|
|
@ -1,410 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright (C) 2014 Christoph Reiter
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of version 2 of the GNU General Public License as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""
|
|
||||||
* ADTS - Audio Data Transport Stream
|
|
||||||
* ADIF - Audio Data Interchange Format
|
|
||||||
* See ISO/IEC 13818-7 / 14496-03
|
|
||||||
"""
|
|
||||||
|
|
||||||
from mutagen import StreamInfo
|
|
||||||
from mutagen._file import FileType
|
|
||||||
from mutagen._util import BitReader, BitReaderError, MutagenError
|
|
||||||
from mutagen._compat import endswith, xrange
|
|
||||||
|
|
||||||
|
|
||||||
_FREQS = [
|
|
||||||
96000, 88200, 64000, 48000,
|
|
||||||
44100, 32000, 24000, 22050,
|
|
||||||
16000, 12000, 11025, 8000,
|
|
||||||
7350,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class _ADTSStream(object):
|
|
||||||
"""Represents a series of frames belonging to the same stream"""
|
|
||||||
|
|
||||||
parsed_frames = 0
|
|
||||||
"""Number of successfully parsed frames"""
|
|
||||||
|
|
||||||
offset = 0
|
|
||||||
"""offset in bytes at which the stream starts (the first sync word)"""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def find_stream(cls, fileobj, max_bytes):
|
|
||||||
"""Returns a possibly valid _ADTSStream or None.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
max_bytes (int): maximum bytes to read
|
|
||||||
"""
|
|
||||||
|
|
||||||
r = BitReader(fileobj)
|
|
||||||
stream = cls(r)
|
|
||||||
if stream.sync(max_bytes):
|
|
||||||
stream.offset = (r.get_position() - 12) // 8
|
|
||||||
return stream
|
|
||||||
|
|
||||||
def sync(self, max_bytes):
|
|
||||||
"""Find the next sync.
|
|
||||||
Returns True if found."""
|
|
||||||
|
|
||||||
# at least 2 bytes for the sync
|
|
||||||
max_bytes = max(max_bytes, 2)
|
|
||||||
|
|
||||||
r = self._r
|
|
||||||
r.align()
|
|
||||||
while max_bytes > 0:
|
|
||||||
try:
|
|
||||||
b = r.bytes(1)
|
|
||||||
if b == b"\xff":
|
|
||||||
if r.bits(4) == 0xf:
|
|
||||||
return True
|
|
||||||
r.align()
|
|
||||||
max_bytes -= 2
|
|
||||||
else:
|
|
||||||
max_bytes -= 1
|
|
||||||
except BitReaderError:
|
|
||||||
return False
|
|
||||||
return False
|
|
||||||
|
|
||||||
def __init__(self, r):
|
|
||||||
"""Use _ADTSStream.find_stream to create a stream"""
|
|
||||||
|
|
||||||
self._fixed_header_key = None
|
|
||||||
self._r = r
|
|
||||||
self.offset = -1
|
|
||||||
self.parsed_frames = 0
|
|
||||||
|
|
||||||
self._samples = 0
|
|
||||||
self._payload = 0
|
|
||||||
self._start = r.get_position() / 8
|
|
||||||
self._last = self._start
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bitrate(self):
|
|
||||||
"""Bitrate of the raw aac blocks, excluding framing/crc"""
|
|
||||||
|
|
||||||
assert self.parsed_frames, "no frame parsed yet"
|
|
||||||
|
|
||||||
if self._samples == 0:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
return (8 * self._payload * self.frequency) // self._samples
|
|
||||||
|
|
||||||
@property
|
|
||||||
def samples(self):
|
|
||||||
"""samples so far"""
|
|
||||||
|
|
||||||
assert self.parsed_frames, "no frame parsed yet"
|
|
||||||
|
|
||||||
return self._samples
|
|
||||||
|
|
||||||
@property
|
|
||||||
def size(self):
|
|
||||||
"""bytes read in the stream so far (including framing)"""
|
|
||||||
|
|
||||||
assert self.parsed_frames, "no frame parsed yet"
|
|
||||||
|
|
||||||
return self._last - self._start
|
|
||||||
|
|
||||||
@property
|
|
||||||
def channels(self):
|
|
||||||
"""0 means unknown"""
|
|
||||||
|
|
||||||
assert self.parsed_frames, "no frame parsed yet"
|
|
||||||
|
|
||||||
b_index = self._fixed_header_key[6]
|
|
||||||
if b_index == 7:
|
|
||||||
return 8
|
|
||||||
elif b_index > 7:
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
return b_index
|
|
||||||
|
|
||||||
@property
|
|
||||||
def frequency(self):
|
|
||||||
"""0 means unknown"""
|
|
||||||
|
|
||||||
assert self.parsed_frames, "no frame parsed yet"
|
|
||||||
|
|
||||||
f_index = self._fixed_header_key[4]
|
|
||||||
try:
|
|
||||||
return _FREQS[f_index]
|
|
||||||
except IndexError:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def parse_frame(self):
|
|
||||||
"""True if parsing was successful.
|
|
||||||
Fails either because the frame wasn't valid or the stream ended.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
return self._parse_frame()
|
|
||||||
except BitReaderError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _parse_frame(self):
|
|
||||||
r = self._r
|
|
||||||
# start == position of sync word
|
|
||||||
start = r.get_position() - 12
|
|
||||||
|
|
||||||
# adts_fixed_header
|
|
||||||
id_ = r.bits(1)
|
|
||||||
layer = r.bits(2)
|
|
||||||
protection_absent = r.bits(1)
|
|
||||||
|
|
||||||
profile = r.bits(2)
|
|
||||||
sampling_frequency_index = r.bits(4)
|
|
||||||
private_bit = r.bits(1)
|
|
||||||
# TODO: if 0 we could parse program_config_element()
|
|
||||||
channel_configuration = r.bits(3)
|
|
||||||
original_copy = r.bits(1)
|
|
||||||
home = r.bits(1)
|
|
||||||
|
|
||||||
# the fixed header has to be the same for every frame in the stream
|
|
||||||
fixed_header_key = (
|
|
||||||
id_, layer, protection_absent, profile, sampling_frequency_index,
|
|
||||||
private_bit, channel_configuration, original_copy, home,
|
|
||||||
)
|
|
||||||
|
|
||||||
if self._fixed_header_key is None:
|
|
||||||
self._fixed_header_key = fixed_header_key
|
|
||||||
else:
|
|
||||||
if self._fixed_header_key != fixed_header_key:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# adts_variable_header
|
|
||||||
r.skip(2) # copyright_identification_bit/start
|
|
||||||
frame_length = r.bits(13)
|
|
||||||
r.skip(11) # adts_buffer_fullness
|
|
||||||
nordbif = r.bits(2)
|
|
||||||
# adts_variable_header end
|
|
||||||
|
|
||||||
crc_overhead = 0
|
|
||||||
if not protection_absent:
|
|
||||||
crc_overhead += (nordbif + 1) * 16
|
|
||||||
if nordbif != 0:
|
|
||||||
crc_overhead *= 2
|
|
||||||
|
|
||||||
left = (frame_length * 8) - (r.get_position() - start)
|
|
||||||
if left < 0:
|
|
||||||
return False
|
|
||||||
r.skip(left)
|
|
||||||
assert r.is_aligned()
|
|
||||||
|
|
||||||
self._payload += (left - crc_overhead) / 8
|
|
||||||
self._samples += (nordbif + 1) * 1024
|
|
||||||
self._last = r.get_position() / 8
|
|
||||||
|
|
||||||
self.parsed_frames += 1
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class ProgramConfigElement(object):
|
|
||||||
|
|
||||||
element_instance_tag = None
|
|
||||||
object_type = None
|
|
||||||
sampling_frequency_index = None
|
|
||||||
channels = None
|
|
||||||
|
|
||||||
def __init__(self, r):
|
|
||||||
"""Reads the program_config_element()
|
|
||||||
|
|
||||||
Raises BitReaderError
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.element_instance_tag = r.bits(4)
|
|
||||||
self.object_type = r.bits(2)
|
|
||||||
self.sampling_frequency_index = r.bits(4)
|
|
||||||
num_front_channel_elements = r.bits(4)
|
|
||||||
num_side_channel_elements = r.bits(4)
|
|
||||||
num_back_channel_elements = r.bits(4)
|
|
||||||
num_lfe_channel_elements = r.bits(2)
|
|
||||||
num_assoc_data_elements = r.bits(3)
|
|
||||||
num_valid_cc_elements = r.bits(4)
|
|
||||||
|
|
||||||
mono_mixdown_present = r.bits(1)
|
|
||||||
if mono_mixdown_present == 1:
|
|
||||||
r.skip(4)
|
|
||||||
stereo_mixdown_present = r.bits(1)
|
|
||||||
if stereo_mixdown_present == 1:
|
|
||||||
r.skip(4)
|
|
||||||
matrix_mixdown_idx_present = r.bits(1)
|
|
||||||
if matrix_mixdown_idx_present == 1:
|
|
||||||
r.skip(3)
|
|
||||||
|
|
||||||
elms = num_front_channel_elements + num_side_channel_elements + \
|
|
||||||
num_back_channel_elements
|
|
||||||
channels = 0
|
|
||||||
for i in xrange(elms):
|
|
||||||
channels += 1
|
|
||||||
element_is_cpe = r.bits(1)
|
|
||||||
if element_is_cpe:
|
|
||||||
channels += 1
|
|
||||||
r.skip(4)
|
|
||||||
channels += num_lfe_channel_elements
|
|
||||||
self.channels = channels
|
|
||||||
|
|
||||||
r.skip(4 * num_lfe_channel_elements)
|
|
||||||
r.skip(4 * num_assoc_data_elements)
|
|
||||||
r.skip(5 * num_valid_cc_elements)
|
|
||||||
r.align()
|
|
||||||
comment_field_bytes = r.bits(8)
|
|
||||||
r.skip(8 * comment_field_bytes)
|
|
||||||
|
|
||||||
|
|
||||||
class AACError(MutagenError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class AACInfo(StreamInfo):
|
|
||||||
"""AAC stream information.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
* channels -- number of audio channels
|
|
||||||
* length -- file length in seconds, as a float
|
|
||||||
* sample_rate -- audio sampling rate in Hz
|
|
||||||
* bitrate -- audio bitrate, in bits per second
|
|
||||||
|
|
||||||
The length of the stream is just a guess and might not be correct.
|
|
||||||
"""
|
|
||||||
|
|
||||||
channels = 0
|
|
||||||
length = 0
|
|
||||||
sample_rate = 0
|
|
||||||
bitrate = 0
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
# skip id3v2 header
|
|
||||||
start_offset = 0
|
|
||||||
header = fileobj.read(10)
|
|
||||||
from mutagen.id3 import BitPaddedInt
|
|
||||||
if header.startswith(b"ID3"):
|
|
||||||
size = BitPaddedInt(header[6:])
|
|
||||||
start_offset = size + 10
|
|
||||||
|
|
||||||
fileobj.seek(start_offset)
|
|
||||||
adif = fileobj.read(4)
|
|
||||||
if adif == b"ADIF":
|
|
||||||
self._parse_adif(fileobj)
|
|
||||||
self._type = "ADIF"
|
|
||||||
else:
|
|
||||||
self._parse_adts(fileobj, start_offset)
|
|
||||||
self._type = "ADTS"
|
|
||||||
|
|
||||||
def _parse_adif(self, fileobj):
|
|
||||||
r = BitReader(fileobj)
|
|
||||||
try:
|
|
||||||
copyright_id_present = r.bits(1)
|
|
||||||
if copyright_id_present:
|
|
||||||
r.skip(72) # copyright_id
|
|
||||||
r.skip(1 + 1) # original_copy, home
|
|
||||||
bitstream_type = r.bits(1)
|
|
||||||
self.bitrate = r.bits(23)
|
|
||||||
npce = r.bits(4)
|
|
||||||
if bitstream_type == 0:
|
|
||||||
r.skip(20) # adif_buffer_fullness
|
|
||||||
|
|
||||||
pce = ProgramConfigElement(r)
|
|
||||||
try:
|
|
||||||
self.sample_rate = _FREQS[pce.sampling_frequency_index]
|
|
||||||
except IndexError:
|
|
||||||
pass
|
|
||||||
self.channels = pce.channels
|
|
||||||
|
|
||||||
# other pces..
|
|
||||||
for i in xrange(npce):
|
|
||||||
ProgramConfigElement(r)
|
|
||||||
r.align()
|
|
||||||
except BitReaderError as e:
|
|
||||||
raise AACError(e)
|
|
||||||
|
|
||||||
# use bitrate + data size to guess length
|
|
||||||
start = fileobj.tell()
|
|
||||||
fileobj.seek(0, 2)
|
|
||||||
length = fileobj.tell() - start
|
|
||||||
if self.bitrate != 0:
|
|
||||||
self.length = (8.0 * length) / self.bitrate
|
|
||||||
|
|
||||||
def _parse_adts(self, fileobj, start_offset):
|
|
||||||
max_initial_read = 512
|
|
||||||
max_resync_read = 10
|
|
||||||
max_sync_tries = 10
|
|
||||||
|
|
||||||
frames_max = 100
|
|
||||||
frames_needed = 3
|
|
||||||
|
|
||||||
# Try up to X times to find a sync word and read up to Y frames.
|
|
||||||
# If more than Z frames are valid we assume a valid stream
|
|
||||||
offset = start_offset
|
|
||||||
for i in xrange(max_sync_tries):
|
|
||||||
fileobj.seek(offset)
|
|
||||||
s = _ADTSStream.find_stream(fileobj, max_initial_read)
|
|
||||||
if s is None:
|
|
||||||
raise AACError("sync not found")
|
|
||||||
# start right after the last found offset
|
|
||||||
offset += s.offset + 1
|
|
||||||
|
|
||||||
for i in xrange(frames_max):
|
|
||||||
if not s.parse_frame():
|
|
||||||
break
|
|
||||||
if not s.sync(max_resync_read):
|
|
||||||
break
|
|
||||||
|
|
||||||
if s.parsed_frames >= frames_needed:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise AACError(
|
|
||||||
"no valid stream found (only %d frames)" % s.parsed_frames)
|
|
||||||
|
|
||||||
self.sample_rate = s.frequency
|
|
||||||
self.channels = s.channels
|
|
||||||
self.bitrate = s.bitrate
|
|
||||||
|
|
||||||
# size from stream start to end of file
|
|
||||||
fileobj.seek(0, 2)
|
|
||||||
stream_size = fileobj.tell() - (offset + s.offset)
|
|
||||||
# approx
|
|
||||||
self.length = float(s.samples * stream_size) / (s.size * s.frequency)
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
return u"AAC (%s), %d Hz, %.2f seconds, %d channel(s), %d bps" % (
|
|
||||||
self._type, self.sample_rate, self.length, self.channels,
|
|
||||||
self.bitrate)
|
|
||||||
|
|
||||||
|
|
||||||
class AAC(FileType):
|
|
||||||
"""Load ADTS or ADIF streams containing AAC.
|
|
||||||
|
|
||||||
Tagging is not supported.
|
|
||||||
Use the ID3/APEv2 classes directly instead.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_mimes = ["audio/x-aac"]
|
|
||||||
|
|
||||||
def load(self, filename):
|
|
||||||
self.filename = filename
|
|
||||||
with open(filename, "rb") as h:
|
|
||||||
self.info = AACInfo(h)
|
|
||||||
|
|
||||||
def add_tags(self):
|
|
||||||
raise AACError("doesn't support tags")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score(filename, fileobj, header):
|
|
||||||
filename = filename.lower()
|
|
||||||
s = endswith(filename, ".aac") or endswith(filename, ".adts") or \
|
|
||||||
endswith(filename, ".adif")
|
|
||||||
s += b"ADIF" in header
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
Open = AAC
|
|
||||||
error = AACError
|
|
||||||
|
|
||||||
__all__ = ["AAC", "Open"]
|
|
|
@ -1,357 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2014 Evan Purkhiser
|
|
||||||
# 2014 Ben Ockmore
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of version 2 of the GNU General Public License as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""AIFF audio stream information and tags."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import struct
|
|
||||||
from struct import pack
|
|
||||||
|
|
||||||
from ._compat import endswith, text_type, reraise
|
|
||||||
from mutagen import StreamInfo, FileType
|
|
||||||
|
|
||||||
from mutagen.id3 import ID3
|
|
||||||
from mutagen.id3._util import ID3NoHeaderError, error as ID3Error
|
|
||||||
from mutagen._util import resize_bytes, delete_bytes, MutagenError
|
|
||||||
|
|
||||||
__all__ = ["AIFF", "Open", "delete"]
|
|
||||||
|
|
||||||
|
|
||||||
class error(MutagenError, RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidChunk(error, IOError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# based on stdlib's aifc
|
|
||||||
_HUGE_VAL = 1.79769313486231e+308
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_chunk_id(id):
|
|
||||||
assert isinstance(id, text_type)
|
|
||||||
|
|
||||||
return ((len(id) <= 4) and (min(id) >= u' ') and
|
|
||||||
(max(id) <= u'~'))
|
|
||||||
|
|
||||||
|
|
||||||
def read_float(data): # 10 bytes
|
|
||||||
expon, himant, lomant = struct.unpack('>hLL', data)
|
|
||||||
sign = 1
|
|
||||||
if expon < 0:
|
|
||||||
sign = -1
|
|
||||||
expon = expon + 0x8000
|
|
||||||
if expon == himant == lomant == 0:
|
|
||||||
f = 0.0
|
|
||||||
elif expon == 0x7FFF:
|
|
||||||
f = _HUGE_VAL
|
|
||||||
else:
|
|
||||||
expon = expon - 16383
|
|
||||||
f = (himant * 0x100000000 + lomant) * pow(2.0, expon - 63)
|
|
||||||
return sign * f
|
|
||||||
|
|
||||||
|
|
||||||
class IFFChunk(object):
|
|
||||||
"""Representation of a single IFF chunk"""
|
|
||||||
|
|
||||||
# Chunk headers are 8 bytes long (4 for ID and 4 for the size)
|
|
||||||
HEADER_SIZE = 8
|
|
||||||
|
|
||||||
def __init__(self, fileobj, parent_chunk=None):
|
|
||||||
self.__fileobj = fileobj
|
|
||||||
self.parent_chunk = parent_chunk
|
|
||||||
self.offset = fileobj.tell()
|
|
||||||
|
|
||||||
header = fileobj.read(self.HEADER_SIZE)
|
|
||||||
if len(header) < self.HEADER_SIZE:
|
|
||||||
raise InvalidChunk()
|
|
||||||
|
|
||||||
self.id, self.data_size = struct.unpack('>4si', header)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.id = self.id.decode('ascii')
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
raise InvalidChunk()
|
|
||||||
|
|
||||||
if not is_valid_chunk_id(self.id):
|
|
||||||
raise InvalidChunk()
|
|
||||||
|
|
||||||
self.size = self.HEADER_SIZE + self.data_size
|
|
||||||
self.data_offset = fileobj.tell()
|
|
||||||
|
|
||||||
def read(self):
|
|
||||||
"""Read the chunks data"""
|
|
||||||
|
|
||||||
self.__fileobj.seek(self.data_offset)
|
|
||||||
return self.__fileobj.read(self.data_size)
|
|
||||||
|
|
||||||
def write(self, data):
|
|
||||||
"""Write the chunk data"""
|
|
||||||
|
|
||||||
if len(data) > self.data_size:
|
|
||||||
raise ValueError
|
|
||||||
|
|
||||||
self.__fileobj.seek(self.data_offset)
|
|
||||||
self.__fileobj.write(data)
|
|
||||||
|
|
||||||
def delete(self):
|
|
||||||
"""Removes the chunk from the file"""
|
|
||||||
|
|
||||||
delete_bytes(self.__fileobj, self.size, self.offset)
|
|
||||||
if self.parent_chunk is not None:
|
|
||||||
self.parent_chunk._update_size(
|
|
||||||
self.parent_chunk.data_size - self.size)
|
|
||||||
|
|
||||||
def _update_size(self, data_size):
|
|
||||||
"""Update the size of the chunk"""
|
|
||||||
|
|
||||||
self.__fileobj.seek(self.offset + 4)
|
|
||||||
self.__fileobj.write(pack('>I', data_size))
|
|
||||||
if self.parent_chunk is not None:
|
|
||||||
size_diff = self.data_size - data_size
|
|
||||||
self.parent_chunk._update_size(
|
|
||||||
self.parent_chunk.data_size - size_diff)
|
|
||||||
self.data_size = data_size
|
|
||||||
self.size = data_size + self.HEADER_SIZE
|
|
||||||
|
|
||||||
def resize(self, new_data_size):
|
|
||||||
"""Resize the file and update the chunk sizes"""
|
|
||||||
|
|
||||||
resize_bytes(
|
|
||||||
self.__fileobj, self.data_size, new_data_size, self.data_offset)
|
|
||||||
self._update_size(new_data_size)
|
|
||||||
|
|
||||||
|
|
||||||
class IFFFile(object):
|
|
||||||
"""Representation of a IFF file"""
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
self.__fileobj = fileobj
|
|
||||||
self.__chunks = {}
|
|
||||||
|
|
||||||
# AIFF Files always start with the FORM chunk which contains a 4 byte
|
|
||||||
# ID before the start of other chunks
|
|
||||||
fileobj.seek(0)
|
|
||||||
self.__chunks[u'FORM'] = IFFChunk(fileobj)
|
|
||||||
|
|
||||||
# Skip past the 4 byte FORM id
|
|
||||||
fileobj.seek(IFFChunk.HEADER_SIZE + 4)
|
|
||||||
|
|
||||||
# Where the next chunk can be located. We need to keep track of this
|
|
||||||
# since the size indicated in the FORM header may not match up with the
|
|
||||||
# offset determined from the size of the last chunk in the file
|
|
||||||
self.__next_offset = fileobj.tell()
|
|
||||||
|
|
||||||
# Load all of the chunks
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
chunk = IFFChunk(fileobj, self[u'FORM'])
|
|
||||||
except InvalidChunk:
|
|
||||||
break
|
|
||||||
self.__chunks[chunk.id.strip()] = chunk
|
|
||||||
|
|
||||||
# Calculate the location of the next chunk,
|
|
||||||
# considering the pad byte
|
|
||||||
self.__next_offset = chunk.offset + chunk.size
|
|
||||||
self.__next_offset += self.__next_offset % 2
|
|
||||||
fileobj.seek(self.__next_offset)
|
|
||||||
|
|
||||||
def __contains__(self, id_):
|
|
||||||
"""Check if the IFF file contains a specific chunk"""
|
|
||||||
|
|
||||||
assert isinstance(id_, text_type)
|
|
||||||
|
|
||||||
if not is_valid_chunk_id(id_):
|
|
||||||
raise KeyError("AIFF key must be four ASCII characters.")
|
|
||||||
|
|
||||||
return id_ in self.__chunks
|
|
||||||
|
|
||||||
def __getitem__(self, id_):
|
|
||||||
"""Get a chunk from the IFF file"""
|
|
||||||
|
|
||||||
assert isinstance(id_, text_type)
|
|
||||||
|
|
||||||
if not is_valid_chunk_id(id_):
|
|
||||||
raise KeyError("AIFF key must be four ASCII characters.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
return self.__chunks[id_]
|
|
||||||
except KeyError:
|
|
||||||
raise KeyError(
|
|
||||||
"%r has no %r chunk" % (self.__fileobj.name, id_))
|
|
||||||
|
|
||||||
def __delitem__(self, id_):
|
|
||||||
"""Remove a chunk from the IFF file"""
|
|
||||||
|
|
||||||
assert isinstance(id_, text_type)
|
|
||||||
|
|
||||||
if not is_valid_chunk_id(id_):
|
|
||||||
raise KeyError("AIFF key must be four ASCII characters.")
|
|
||||||
|
|
||||||
self.__chunks.pop(id_).delete()
|
|
||||||
|
|
||||||
def insert_chunk(self, id_):
|
|
||||||
"""Insert a new chunk at the end of the IFF file"""
|
|
||||||
|
|
||||||
assert isinstance(id_, text_type)
|
|
||||||
|
|
||||||
if not is_valid_chunk_id(id_):
|
|
||||||
raise KeyError("AIFF key must be four ASCII characters.")
|
|
||||||
|
|
||||||
self.__fileobj.seek(self.__next_offset)
|
|
||||||
self.__fileobj.write(pack('>4si', id_.ljust(4).encode('ascii'), 0))
|
|
||||||
self.__fileobj.seek(self.__next_offset)
|
|
||||||
chunk = IFFChunk(self.__fileobj, self[u'FORM'])
|
|
||||||
self[u'FORM']._update_size(self[u'FORM'].data_size + chunk.size)
|
|
||||||
|
|
||||||
self.__chunks[id_] = chunk
|
|
||||||
self.__next_offset = chunk.offset + chunk.size
|
|
||||||
|
|
||||||
|
|
||||||
class AIFFInfo(StreamInfo):
|
|
||||||
"""AIFF audio stream information.
|
|
||||||
|
|
||||||
Information is parsed from the COMM chunk of the AIFF file
|
|
||||||
|
|
||||||
Useful attributes:
|
|
||||||
|
|
||||||
* length -- audio length, in seconds
|
|
||||||
* bitrate -- audio bitrate, in bits per second
|
|
||||||
* channels -- The number of audio channels
|
|
||||||
* sample_rate -- audio sample rate, in Hz
|
|
||||||
* sample_size -- The audio sample size
|
|
||||||
"""
|
|
||||||
|
|
||||||
length = 0
|
|
||||||
bitrate = 0
|
|
||||||
channels = 0
|
|
||||||
sample_rate = 0
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
iff = IFFFile(fileobj)
|
|
||||||
try:
|
|
||||||
common_chunk = iff[u'COMM']
|
|
||||||
except KeyError as e:
|
|
||||||
raise error(str(e))
|
|
||||||
|
|
||||||
data = common_chunk.read()
|
|
||||||
|
|
||||||
info = struct.unpack('>hLh10s', data[:18])
|
|
||||||
channels, frame_count, sample_size, sample_rate = info
|
|
||||||
|
|
||||||
self.sample_rate = int(read_float(sample_rate))
|
|
||||||
self.sample_size = sample_size
|
|
||||||
self.channels = channels
|
|
||||||
self.bitrate = channels * sample_size * self.sample_rate
|
|
||||||
self.length = frame_count / float(self.sample_rate)
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
return u"%d channel AIFF @ %d bps, %s Hz, %.2f seconds" % (
|
|
||||||
self.channels, self.bitrate, self.sample_rate, self.length)
|
|
||||||
|
|
||||||
|
|
||||||
class _IFFID3(ID3):
|
|
||||||
"""A AIFF file with ID3v2 tags"""
|
|
||||||
|
|
||||||
def _pre_load_header(self, fileobj):
|
|
||||||
try:
|
|
||||||
fileobj.seek(IFFFile(fileobj)[u'ID3'].data_offset)
|
|
||||||
except (InvalidChunk, KeyError):
|
|
||||||
raise ID3NoHeaderError("No ID3 chunk")
|
|
||||||
|
|
||||||
def save(self, filename=None, v2_version=4, v23_sep='/', padding=None):
|
|
||||||
"""Save ID3v2 data to the AIFF file"""
|
|
||||||
|
|
||||||
if filename is None:
|
|
||||||
filename = self.filename
|
|
||||||
|
|
||||||
# Unlike the parent ID3.save method, we won't save to a blank file
|
|
||||||
# since we would have to construct a empty AIFF file
|
|
||||||
with open(filename, 'rb+') as fileobj:
|
|
||||||
iff_file = IFFFile(fileobj)
|
|
||||||
|
|
||||||
if u'ID3' not in iff_file:
|
|
||||||
iff_file.insert_chunk(u'ID3')
|
|
||||||
|
|
||||||
chunk = iff_file[u'ID3']
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = self._prepare_data(
|
|
||||||
fileobj, chunk.data_offset, chunk.data_size, v2_version,
|
|
||||||
v23_sep, padding)
|
|
||||||
except ID3Error as e:
|
|
||||||
reraise(error, e, sys.exc_info()[2])
|
|
||||||
|
|
||||||
new_size = len(data)
|
|
||||||
new_size += new_size % 2 # pad byte
|
|
||||||
assert new_size % 2 == 0
|
|
||||||
chunk.resize(new_size)
|
|
||||||
data += (new_size - len(data)) * b'\x00'
|
|
||||||
assert new_size == len(data)
|
|
||||||
chunk.write(data)
|
|
||||||
|
|
||||||
def delete(self, filename=None):
|
|
||||||
"""Completely removes the ID3 chunk from the AIFF file"""
|
|
||||||
|
|
||||||
if filename is None:
|
|
||||||
filename = self.filename
|
|
||||||
delete(filename)
|
|
||||||
self.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def delete(filename):
|
|
||||||
"""Completely removes the ID3 chunk from the AIFF file"""
|
|
||||||
|
|
||||||
with open(filename, "rb+") as file_:
|
|
||||||
try:
|
|
||||||
del IFFFile(file_)[u'ID3']
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class AIFF(FileType):
|
|
||||||
"""An AIFF audio file.
|
|
||||||
|
|
||||||
:ivar info: :class:`AIFFInfo`
|
|
||||||
:ivar tags: :class:`ID3`
|
|
||||||
"""
|
|
||||||
|
|
||||||
_mimes = ["audio/aiff", "audio/x-aiff"]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score(filename, fileobj, header):
|
|
||||||
filename = filename.lower()
|
|
||||||
|
|
||||||
return (header.startswith(b"FORM") * 2 + endswith(filename, b".aif") +
|
|
||||||
endswith(filename, b".aiff") + endswith(filename, b".aifc"))
|
|
||||||
|
|
||||||
def add_tags(self):
|
|
||||||
"""Add an empty ID3 tag to the file."""
|
|
||||||
if self.tags is None:
|
|
||||||
self.tags = _IFFID3()
|
|
||||||
else:
|
|
||||||
raise error("an ID3 tag already exists")
|
|
||||||
|
|
||||||
def load(self, filename, **kwargs):
|
|
||||||
"""Load stream and tag information from a file."""
|
|
||||||
self.filename = filename
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.tags = _IFFID3(filename, **kwargs)
|
|
||||||
except ID3NoHeaderError:
|
|
||||||
self.tags = None
|
|
||||||
except ID3Error as e:
|
|
||||||
raise error(e)
|
|
||||||
|
|
||||||
with open(filename, "rb") as fileobj:
|
|
||||||
self.info = AIFFInfo(fileobj)
|
|
||||||
|
|
||||||
|
|
||||||
Open = AIFF
|
|
|
@ -1,710 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2005 Joe Wreschnig
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""APEv2 reading and writing.
|
|
||||||
|
|
||||||
The APEv2 format is most commonly used with Musepack files, but is
|
|
||||||
also the format of choice for WavPack and other formats. Some MP3s
|
|
||||||
also have APEv2 tags, but this can cause problems with many MP3
|
|
||||||
decoders and taggers.
|
|
||||||
|
|
||||||
APEv2 tags, like Vorbis comments, are freeform key=value pairs. APEv2
|
|
||||||
keys can be any ASCII string with characters from 0x20 to 0x7E,
|
|
||||||
between 2 and 255 characters long. Keys are case-sensitive, but
|
|
||||||
readers are recommended to be case insensitive, and it is forbidden to
|
|
||||||
multiple keys which differ only in case. Keys are usually stored
|
|
||||||
title-cased (e.g. 'Artist' rather than 'artist').
|
|
||||||
|
|
||||||
APEv2 values are slightly more structured than Vorbis comments; values
|
|
||||||
are flagged as one of text, binary, or an external reference (usually
|
|
||||||
a URI).
|
|
||||||
|
|
||||||
Based off the format specification found at
|
|
||||||
http://wiki.hydrogenaudio.org/index.php?title=APEv2_specification.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__all__ = ["APEv2", "APEv2File", "Open", "delete"]
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import struct
|
|
||||||
from collections import MutableSequence
|
|
||||||
|
|
||||||
from ._compat import (cBytesIO, PY3, text_type, PY2, reraise, swap_to_string,
|
|
||||||
xrange)
|
|
||||||
from mutagen import Metadata, FileType, StreamInfo
|
|
||||||
from mutagen._util import (DictMixin, cdata, delete_bytes, total_ordering,
|
|
||||||
MutagenError)
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_apev2_key(key):
|
|
||||||
if not isinstance(key, text_type):
|
|
||||||
if PY3:
|
|
||||||
raise TypeError("APEv2 key must be str")
|
|
||||||
|
|
||||||
try:
|
|
||||||
key = key.decode('ascii')
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# PY26 - Change to set literal syntax (since set is faster than list here)
|
|
||||||
return ((2 <= len(key) <= 255) and (min(key) >= u' ') and
|
|
||||||
(max(key) <= u'~') and
|
|
||||||
(key not in [u"OggS", u"TAG", u"ID3", u"MP+"]))
|
|
||||||
|
|
||||||
# There are three different kinds of APE tag values.
|
|
||||||
# "0: Item contains text information coded in UTF-8
|
|
||||||
# 1: Item contains binary information
|
|
||||||
# 2: Item is a locator of external stored information [e.g. URL]
|
|
||||||
# 3: reserved"
|
|
||||||
TEXT, BINARY, EXTERNAL = xrange(3)
|
|
||||||
|
|
||||||
HAS_HEADER = 1 << 31
|
|
||||||
HAS_NO_FOOTER = 1 << 30
|
|
||||||
IS_HEADER = 1 << 29
|
|
||||||
|
|
||||||
|
|
||||||
class error(IOError, MutagenError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class APENoHeaderError(error, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class APEUnsupportedVersionError(error, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class APEBadItemError(error, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class _APEv2Data(object):
|
|
||||||
# Store offsets of the important parts of the file.
|
|
||||||
start = header = data = footer = end = None
|
|
||||||
# Footer or header; seek here and read 32 to get version/size/items/flags
|
|
||||||
metadata = None
|
|
||||||
# Actual tag data
|
|
||||||
tag = None
|
|
||||||
|
|
||||||
version = None
|
|
||||||
size = None
|
|
||||||
items = None
|
|
||||||
flags = 0
|
|
||||||
|
|
||||||
# The tag is at the start rather than the end. A tag at both
|
|
||||||
# the start and end of the file (i.e. the tag is the whole file)
|
|
||||||
# is not considered to be at the start.
|
|
||||||
is_at_start = False
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
self.__find_metadata(fileobj)
|
|
||||||
|
|
||||||
if self.header is None:
|
|
||||||
self.metadata = self.footer
|
|
||||||
elif self.footer is None:
|
|
||||||
self.metadata = self.header
|
|
||||||
else:
|
|
||||||
self.metadata = max(self.header, self.footer)
|
|
||||||
|
|
||||||
if self.metadata is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.__fill_missing(fileobj)
|
|
||||||
self.__fix_brokenness(fileobj)
|
|
||||||
if self.data is not None:
|
|
||||||
fileobj.seek(self.data)
|
|
||||||
self.tag = fileobj.read(self.size)
|
|
||||||
|
|
||||||
def __find_metadata(self, fileobj):
|
|
||||||
# Try to find a header or footer.
|
|
||||||
|
|
||||||
# Check for a simple footer.
|
|
||||||
try:
|
|
||||||
fileobj.seek(-32, 2)
|
|
||||||
except IOError:
|
|
||||||
fileobj.seek(0, 2)
|
|
||||||
return
|
|
||||||
if fileobj.read(8) == b"APETAGEX":
|
|
||||||
fileobj.seek(-8, 1)
|
|
||||||
self.footer = self.metadata = fileobj.tell()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check for an APEv2 tag followed by an ID3v1 tag at the end.
|
|
||||||
try:
|
|
||||||
fileobj.seek(-128, 2)
|
|
||||||
if fileobj.read(3) == b"TAG":
|
|
||||||
|
|
||||||
fileobj.seek(-35, 1) # "TAG" + header length
|
|
||||||
if fileobj.read(8) == b"APETAGEX":
|
|
||||||
fileobj.seek(-8, 1)
|
|
||||||
self.footer = fileobj.tell()
|
|
||||||
return
|
|
||||||
|
|
||||||
# ID3v1 tag at the end, maybe preceded by Lyrics3v2.
|
|
||||||
# (http://www.id3.org/lyrics3200.html)
|
|
||||||
# (header length - "APETAGEX") - "LYRICS200"
|
|
||||||
fileobj.seek(15, 1)
|
|
||||||
if fileobj.read(9) == b'LYRICS200':
|
|
||||||
fileobj.seek(-15, 1) # "LYRICS200" + size tag
|
|
||||||
try:
|
|
||||||
offset = int(fileobj.read(6))
|
|
||||||
except ValueError:
|
|
||||||
raise IOError
|
|
||||||
|
|
||||||
fileobj.seek(-32 - offset - 6, 1)
|
|
||||||
if fileobj.read(8) == b"APETAGEX":
|
|
||||||
fileobj.seek(-8, 1)
|
|
||||||
self.footer = fileobj.tell()
|
|
||||||
return
|
|
||||||
|
|
||||||
except IOError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check for a tag at the start.
|
|
||||||
fileobj.seek(0, 0)
|
|
||||||
if fileobj.read(8) == b"APETAGEX":
|
|
||||||
self.is_at_start = True
|
|
||||||
self.header = 0
|
|
||||||
|
|
||||||
def __fill_missing(self, fileobj):
|
|
||||||
fileobj.seek(self.metadata + 8)
|
|
||||||
self.version = fileobj.read(4)
|
|
||||||
self.size = cdata.uint_le(fileobj.read(4))
|
|
||||||
self.items = cdata.uint_le(fileobj.read(4))
|
|
||||||
self.flags = cdata.uint_le(fileobj.read(4))
|
|
||||||
|
|
||||||
if self.header is not None:
|
|
||||||
self.data = self.header + 32
|
|
||||||
# If we're reading the header, the size is the header
|
|
||||||
# offset + the size, which includes the footer.
|
|
||||||
self.end = self.data + self.size
|
|
||||||
fileobj.seek(self.end - 32, 0)
|
|
||||||
if fileobj.read(8) == b"APETAGEX":
|
|
||||||
self.footer = self.end - 32
|
|
||||||
elif self.footer is not None:
|
|
||||||
self.end = self.footer + 32
|
|
||||||
self.data = self.end - self.size
|
|
||||||
if self.flags & HAS_HEADER:
|
|
||||||
self.header = self.data - 32
|
|
||||||
else:
|
|
||||||
self.header = self.data
|
|
||||||
else:
|
|
||||||
raise APENoHeaderError("No APE tag found")
|
|
||||||
|
|
||||||
# exclude the footer from size
|
|
||||||
if self.footer is not None:
|
|
||||||
self.size -= 32
|
|
||||||
|
|
||||||
def __fix_brokenness(self, fileobj):
|
|
||||||
# Fix broken tags written with PyMusepack.
|
|
||||||
if self.header is not None:
|
|
||||||
start = self.header
|
|
||||||
else:
|
|
||||||
start = self.data
|
|
||||||
fileobj.seek(start)
|
|
||||||
|
|
||||||
while start > 0:
|
|
||||||
# Clean up broken writing from pre-Mutagen PyMusepack.
|
|
||||||
# It didn't remove the first 24 bytes of header.
|
|
||||||
try:
|
|
||||||
fileobj.seek(-24, 1)
|
|
||||||
except IOError:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
if fileobj.read(8) == b"APETAGEX":
|
|
||||||
fileobj.seek(-8, 1)
|
|
||||||
start = fileobj.tell()
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
self.start = start
|
|
||||||
|
|
||||||
|
|
||||||
class _CIDictProxy(DictMixin):
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
self.__casemap = {}
|
|
||||||
self.__dict = {}
|
|
||||||
super(_CIDictProxy, self).__init__(*args, **kwargs)
|
|
||||||
# Internally all names are stored as lowercase, but the case
|
|
||||||
# they were set with is remembered and used when saving. This
|
|
||||||
# is roughly in line with the standard, which says that keys
|
|
||||||
# are case-sensitive but two keys differing only in case are
|
|
||||||
# not allowed, and recommends case-insensitive
|
|
||||||
# implementations.
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
return self.__dict[key.lower()]
|
|
||||||
|
|
||||||
def __setitem__(self, key, value):
|
|
||||||
lower = key.lower()
|
|
||||||
self.__casemap[lower] = key
|
|
||||||
self.__dict[lower] = value
|
|
||||||
|
|
||||||
def __delitem__(self, key):
|
|
||||||
lower = key.lower()
|
|
||||||
del(self.__casemap[lower])
|
|
||||||
del(self.__dict[lower])
|
|
||||||
|
|
||||||
def keys(self):
|
|
||||||
return [self.__casemap.get(key, key) for key in self.__dict.keys()]
|
|
||||||
|
|
||||||
|
|
||||||
class APEv2(_CIDictProxy, Metadata):
|
|
||||||
"""A file with an APEv2 tag.
|
|
||||||
|
|
||||||
ID3v1 tags are silently ignored and overwritten.
|
|
||||||
"""
|
|
||||||
|
|
||||||
filename = None
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
"""Return tag key=value pairs in a human-readable format."""
|
|
||||||
|
|
||||||
items = sorted(self.items())
|
|
||||||
return u"\n".join(u"%s=%s" % (k, v.pprint()) for k, v in items)
|
|
||||||
|
|
||||||
def load(self, filename):
|
|
||||||
"""Load tags from a filename."""
|
|
||||||
|
|
||||||
self.filename = filename
|
|
||||||
with open(filename, "rb") as fileobj:
|
|
||||||
data = _APEv2Data(fileobj)
|
|
||||||
|
|
||||||
if data.tag:
|
|
||||||
self.clear()
|
|
||||||
self.__parse_tag(data.tag, data.items)
|
|
||||||
else:
|
|
||||||
raise APENoHeaderError("No APE tag found")
|
|
||||||
|
|
||||||
def __parse_tag(self, tag, count):
|
|
||||||
fileobj = cBytesIO(tag)
|
|
||||||
|
|
||||||
for i in xrange(count):
|
|
||||||
size_data = fileobj.read(4)
|
|
||||||
# someone writes wrong item counts
|
|
||||||
if not size_data:
|
|
||||||
break
|
|
||||||
size = cdata.uint_le(size_data)
|
|
||||||
flags = cdata.uint_le(fileobj.read(4))
|
|
||||||
|
|
||||||
# Bits 1 and 2 bits are flags, 0-3
|
|
||||||
# Bit 0 is read/write flag, ignored
|
|
||||||
kind = (flags & 6) >> 1
|
|
||||||
if kind == 3:
|
|
||||||
raise APEBadItemError("value type must be 0, 1, or 2")
|
|
||||||
key = value = fileobj.read(1)
|
|
||||||
while key[-1:] != b'\x00' and value:
|
|
||||||
value = fileobj.read(1)
|
|
||||||
key += value
|
|
||||||
if key[-1:] == b"\x00":
|
|
||||||
key = key[:-1]
|
|
||||||
if PY3:
|
|
||||||
try:
|
|
||||||
key = key.decode("ascii")
|
|
||||||
except UnicodeError as err:
|
|
||||||
reraise(APEBadItemError, err, sys.exc_info()[2])
|
|
||||||
value = fileobj.read(size)
|
|
||||||
|
|
||||||
value = _get_value_type(kind)._new(value)
|
|
||||||
|
|
||||||
self[key] = value
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
if not is_valid_apev2_key(key):
|
|
||||||
raise KeyError("%r is not a valid APEv2 key" % key)
|
|
||||||
if PY2:
|
|
||||||
key = key.encode('ascii')
|
|
||||||
|
|
||||||
return super(APEv2, self).__getitem__(key)
|
|
||||||
|
|
||||||
def __delitem__(self, key):
|
|
||||||
if not is_valid_apev2_key(key):
|
|
||||||
raise KeyError("%r is not a valid APEv2 key" % key)
|
|
||||||
if PY2:
|
|
||||||
key = key.encode('ascii')
|
|
||||||
|
|
||||||
super(APEv2, self).__delitem__(key)
|
|
||||||
|
|
||||||
def __setitem__(self, key, value):
|
|
||||||
"""'Magic' value setter.
|
|
||||||
|
|
||||||
This function tries to guess at what kind of value you want to
|
|
||||||
store. If you pass in a valid UTF-8 or Unicode string, it
|
|
||||||
treats it as a text value. If you pass in a list, it treats it
|
|
||||||
as a list of string/Unicode values. If you pass in a string
|
|
||||||
that is not valid UTF-8, it assumes it is a binary value.
|
|
||||||
|
|
||||||
Python 3: all bytes will be assumed to be a byte value, even
|
|
||||||
if they are valid utf-8.
|
|
||||||
|
|
||||||
If you need to force a specific type of value (e.g. binary
|
|
||||||
data that also happens to be valid UTF-8, or an external
|
|
||||||
reference), use the APEValue factory and set the value to the
|
|
||||||
result of that::
|
|
||||||
|
|
||||||
from mutagen.apev2 import APEValue, EXTERNAL
|
|
||||||
tag['Website'] = APEValue('http://example.org', EXTERNAL)
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not is_valid_apev2_key(key):
|
|
||||||
raise KeyError("%r is not a valid APEv2 key" % key)
|
|
||||||
|
|
||||||
if PY2:
|
|
||||||
key = key.encode('ascii')
|
|
||||||
|
|
||||||
if not isinstance(value, _APEValue):
|
|
||||||
# let's guess at the content if we're not already a value...
|
|
||||||
if isinstance(value, text_type):
|
|
||||||
# unicode? we've got to be text.
|
|
||||||
value = APEValue(value, TEXT)
|
|
||||||
elif isinstance(value, list):
|
|
||||||
items = []
|
|
||||||
for v in value:
|
|
||||||
if not isinstance(v, text_type):
|
|
||||||
if PY3:
|
|
||||||
raise TypeError("item in list not str")
|
|
||||||
v = v.decode("utf-8")
|
|
||||||
items.append(v)
|
|
||||||
|
|
||||||
# list? text.
|
|
||||||
value = APEValue(u"\0".join(items), TEXT)
|
|
||||||
else:
|
|
||||||
if PY3:
|
|
||||||
value = APEValue(value, BINARY)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
value.decode("utf-8")
|
|
||||||
except UnicodeError:
|
|
||||||
# invalid UTF8 text, probably binary
|
|
||||||
value = APEValue(value, BINARY)
|
|
||||||
else:
|
|
||||||
# valid UTF8, probably text
|
|
||||||
value = APEValue(value, TEXT)
|
|
||||||
|
|
||||||
super(APEv2, self).__setitem__(key, value)
|
|
||||||
|
|
||||||
def save(self, filename=None):
|
|
||||||
"""Save changes to a file.
|
|
||||||
|
|
||||||
If no filename is given, the one most recently loaded is used.
|
|
||||||
|
|
||||||
Tags are always written at the end of the file, and include
|
|
||||||
a header and a footer.
|
|
||||||
"""
|
|
||||||
|
|
||||||
filename = filename or self.filename
|
|
||||||
try:
|
|
||||||
fileobj = open(filename, "r+b")
|
|
||||||
except IOError:
|
|
||||||
fileobj = open(filename, "w+b")
|
|
||||||
data = _APEv2Data(fileobj)
|
|
||||||
|
|
||||||
if data.is_at_start:
|
|
||||||
delete_bytes(fileobj, data.end - data.start, data.start)
|
|
||||||
elif data.start is not None:
|
|
||||||
fileobj.seek(data.start)
|
|
||||||
# Delete an ID3v1 tag if present, too.
|
|
||||||
fileobj.truncate()
|
|
||||||
fileobj.seek(0, 2)
|
|
||||||
|
|
||||||
tags = []
|
|
||||||
for key, value in self.items():
|
|
||||||
# Packed format for an item:
|
|
||||||
# 4B: Value length
|
|
||||||
# 4B: Value type
|
|
||||||
# Key name
|
|
||||||
# 1B: Null
|
|
||||||
# Key value
|
|
||||||
value_data = value._write()
|
|
||||||
if not isinstance(key, bytes):
|
|
||||||
key = key.encode("utf-8")
|
|
||||||
tag_data = bytearray()
|
|
||||||
tag_data += struct.pack("<2I", len(value_data), value.kind << 1)
|
|
||||||
tag_data += key + b"\0" + value_data
|
|
||||||
tags.append(bytes(tag_data))
|
|
||||||
|
|
||||||
# "APE tags items should be sorted ascending by size... This is
|
|
||||||
# not a MUST, but STRONGLY recommended. Actually the items should
|
|
||||||
# be sorted by importance/byte, but this is not feasible."
|
|
||||||
tags.sort(key=len)
|
|
||||||
num_tags = len(tags)
|
|
||||||
tags = b"".join(tags)
|
|
||||||
|
|
||||||
header = bytearray(b"APETAGEX")
|
|
||||||
# version, tag size, item count, flags
|
|
||||||
header += struct.pack("<4I", 2000, len(tags) + 32, num_tags,
|
|
||||||
HAS_HEADER | IS_HEADER)
|
|
||||||
header += b"\0" * 8
|
|
||||||
fileobj.write(header)
|
|
||||||
|
|
||||||
fileobj.write(tags)
|
|
||||||
|
|
||||||
footer = bytearray(b"APETAGEX")
|
|
||||||
footer += struct.pack("<4I", 2000, len(tags) + 32, num_tags,
|
|
||||||
HAS_HEADER)
|
|
||||||
footer += b"\0" * 8
|
|
||||||
|
|
||||||
fileobj.write(footer)
|
|
||||||
fileobj.close()
|
|
||||||
|
|
||||||
def delete(self, filename=None):
|
|
||||||
"""Remove tags from a file."""
|
|
||||||
|
|
||||||
filename = filename or self.filename
|
|
||||||
with open(filename, "r+b") as fileobj:
|
|
||||||
data = _APEv2Data(fileobj)
|
|
||||||
if data.start is not None and data.size is not None:
|
|
||||||
delete_bytes(fileobj, data.end - data.start, data.start)
|
|
||||||
|
|
||||||
self.clear()
|
|
||||||
|
|
||||||
|
|
||||||
Open = APEv2
|
|
||||||
|
|
||||||
|
|
||||||
def delete(filename):
|
|
||||||
"""Remove tags from a file."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
APEv2(filename).delete()
|
|
||||||
except APENoHeaderError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _get_value_type(kind):
|
|
||||||
"""Returns a _APEValue subclass or raises ValueError"""
|
|
||||||
|
|
||||||
if kind == TEXT:
|
|
||||||
return APETextValue
|
|
||||||
elif kind == BINARY:
|
|
||||||
return APEBinaryValue
|
|
||||||
elif kind == EXTERNAL:
|
|
||||||
return APEExtValue
|
|
||||||
raise ValueError("unknown kind %r" % kind)
|
|
||||||
|
|
||||||
|
|
||||||
def APEValue(value, kind):
|
|
||||||
"""APEv2 tag value factory.
|
|
||||||
|
|
||||||
Use this if you need to specify the value's type manually. Binary
|
|
||||||
and text data are automatically detected by APEv2.__setitem__.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
type_ = _get_value_type(kind)
|
|
||||||
except ValueError:
|
|
||||||
raise ValueError("kind must be TEXT, BINARY, or EXTERNAL")
|
|
||||||
else:
|
|
||||||
return type_(value)
|
|
||||||
|
|
||||||
|
|
||||||
class _APEValue(object):
|
|
||||||
|
|
||||||
kind = None
|
|
||||||
value = None
|
|
||||||
|
|
||||||
def __init__(self, value, kind=None):
|
|
||||||
# kind kwarg is for backwards compat
|
|
||||||
if kind is not None and kind != self.kind:
|
|
||||||
raise ValueError
|
|
||||||
self.value = self._validate(value)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _new(cls, data):
|
|
||||||
instance = cls.__new__(cls)
|
|
||||||
instance._parse(data)
|
|
||||||
return instance
|
|
||||||
|
|
||||||
def _parse(self, data):
|
|
||||||
"""Sets value or raises APEBadItemError"""
|
|
||||||
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def _write(self):
|
|
||||||
"""Returns bytes"""
|
|
||||||
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def _validate(self, value):
|
|
||||||
"""Returns validated value or raises TypeError/ValueErrr"""
|
|
||||||
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return "%s(%r, %d)" % (type(self).__name__, self.value, self.kind)
|
|
||||||
|
|
||||||
|
|
||||||
@swap_to_string
|
|
||||||
@total_ordering
|
|
||||||
class _APEUtf8Value(_APEValue):
|
|
||||||
|
|
||||||
def _parse(self, data):
|
|
||||||
try:
|
|
||||||
self.value = data.decode("utf-8")
|
|
||||||
except UnicodeDecodeError as e:
|
|
||||||
reraise(APEBadItemError, e, sys.exc_info()[2])
|
|
||||||
|
|
||||||
def _validate(self, value):
|
|
||||||
if not isinstance(value, text_type):
|
|
||||||
if PY3:
|
|
||||||
raise TypeError("value not str")
|
|
||||||
else:
|
|
||||||
value = value.decode("utf-8")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def _write(self):
|
|
||||||
return self.value.encode("utf-8")
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
return len(self.value)
|
|
||||||
|
|
||||||
def __bytes__(self):
|
|
||||||
return self._write()
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
return self.value == other
|
|
||||||
|
|
||||||
def __lt__(self, other):
|
|
||||||
return self.value < other
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
|
|
||||||
class APETextValue(_APEUtf8Value, MutableSequence):
|
|
||||||
"""An APEv2 text value.
|
|
||||||
|
|
||||||
Text values are Unicode/UTF-8 strings. They can be accessed like
|
|
||||||
strings (with a null separating the values), or arrays of strings.
|
|
||||||
"""
|
|
||||||
|
|
||||||
kind = TEXT
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
"""Iterate over the strings of the value (not the characters)"""
|
|
||||||
|
|
||||||
return iter(self.value.split(u"\0"))
|
|
||||||
|
|
||||||
def __getitem__(self, index):
|
|
||||||
return self.value.split(u"\0")[index]
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
return self.value.count(u"\0") + 1
|
|
||||||
|
|
||||||
def __setitem__(self, index, value):
|
|
||||||
if not isinstance(value, text_type):
|
|
||||||
if PY3:
|
|
||||||
raise TypeError("value not str")
|
|
||||||
else:
|
|
||||||
value = value.decode("utf-8")
|
|
||||||
|
|
||||||
values = list(self)
|
|
||||||
values[index] = value
|
|
||||||
self.value = u"\0".join(values)
|
|
||||||
|
|
||||||
def insert(self, index, value):
|
|
||||||
if not isinstance(value, text_type):
|
|
||||||
if PY3:
|
|
||||||
raise TypeError("value not str")
|
|
||||||
else:
|
|
||||||
value = value.decode("utf-8")
|
|
||||||
|
|
||||||
values = list(self)
|
|
||||||
values.insert(index, value)
|
|
||||||
self.value = u"\0".join(values)
|
|
||||||
|
|
||||||
def __delitem__(self, index):
|
|
||||||
values = list(self)
|
|
||||||
del values[index]
|
|
||||||
self.value = u"\0".join(values)
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
return u" / ".join(self)
|
|
||||||
|
|
||||||
|
|
||||||
@swap_to_string
|
|
||||||
@total_ordering
|
|
||||||
class APEBinaryValue(_APEValue):
|
|
||||||
"""An APEv2 binary value."""
|
|
||||||
|
|
||||||
kind = BINARY
|
|
||||||
|
|
||||||
def _parse(self, data):
|
|
||||||
self.value = data
|
|
||||||
|
|
||||||
def _write(self):
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
def _validate(self, value):
|
|
||||||
if not isinstance(value, bytes):
|
|
||||||
raise TypeError("value not bytes")
|
|
||||||
return bytes(value)
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
return len(self.value)
|
|
||||||
|
|
||||||
def __bytes__(self):
|
|
||||||
return self._write()
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
return self.value == other
|
|
||||||
|
|
||||||
def __lt__(self, other):
|
|
||||||
return self.value < other
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
return u"[%d bytes]" % len(self)
|
|
||||||
|
|
||||||
|
|
||||||
class APEExtValue(_APEUtf8Value):
|
|
||||||
"""An APEv2 external value.
|
|
||||||
|
|
||||||
External values are usually URI or IRI strings.
|
|
||||||
"""
|
|
||||||
|
|
||||||
kind = EXTERNAL
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
return u"[External] %s" % self.value
|
|
||||||
|
|
||||||
|
|
||||||
class APEv2File(FileType):
|
|
||||||
class _Info(StreamInfo):
|
|
||||||
length = 0
|
|
||||||
bitrate = 0
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def pprint():
|
|
||||||
return u"Unknown format with APEv2 tag."
|
|
||||||
|
|
||||||
def load(self, filename):
|
|
||||||
self.filename = filename
|
|
||||||
self.info = self._Info(open(filename, "rb"))
|
|
||||||
try:
|
|
||||||
self.tags = APEv2(filename)
|
|
||||||
except APENoHeaderError:
|
|
||||||
self.tags = None
|
|
||||||
|
|
||||||
def add_tags(self):
|
|
||||||
if self.tags is None:
|
|
||||||
self.tags = APEv2()
|
|
||||||
else:
|
|
||||||
raise error("%r already has tags: %r" % (self, self.tags))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score(filename, fileobj, header):
|
|
||||||
try:
|
|
||||||
fileobj.seek(-160, 2)
|
|
||||||
except IOError:
|
|
||||||
fileobj.seek(0)
|
|
||||||
footer = fileobj.read()
|
|
||||||
return ((b"APETAGEX" in footer) - header.startswith(b"ID3"))
|
|
|
@ -1,319 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright (C) 2005-2006 Joe Wreschnig
|
|
||||||
# Copyright (C) 2006-2007 Lukas Lalinsky
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""Read and write ASF (Window Media Audio) files."""
|
|
||||||
|
|
||||||
__all__ = ["ASF", "Open"]
|
|
||||||
|
|
||||||
from mutagen import FileType, Metadata, StreamInfo
|
|
||||||
from mutagen._util import resize_bytes, DictMixin
|
|
||||||
from mutagen._compat import string_types, long_, PY3, izip
|
|
||||||
|
|
||||||
from ._util import error, ASFError, ASFHeaderError
|
|
||||||
from ._objects import HeaderObject, MetadataLibraryObject, MetadataObject, \
|
|
||||||
ExtendedContentDescriptionObject, HeaderExtensionObject, \
|
|
||||||
ContentDescriptionObject
|
|
||||||
from ._attrs import ASFGUIDAttribute, ASFWordAttribute, ASFQWordAttribute, \
|
|
||||||
ASFDWordAttribute, ASFBoolAttribute, ASFByteArrayAttribute, \
|
|
||||||
ASFUnicodeAttribute, ASFBaseAttribute, ASFValue
|
|
||||||
|
|
||||||
|
|
||||||
# pyflakes
|
|
||||||
error, ASFError, ASFHeaderError, ASFValue
|
|
||||||
|
|
||||||
|
|
||||||
class ASFInfo(StreamInfo):
|
|
||||||
"""ASF stream information."""
|
|
||||||
|
|
||||||
length = 0.0
|
|
||||||
"""Length in seconds (`float`)"""
|
|
||||||
|
|
||||||
sample_rate = 0
|
|
||||||
"""Sample rate in Hz (`int`)"""
|
|
||||||
|
|
||||||
bitrate = 0
|
|
||||||
"""Bitrate in bps (`int`)"""
|
|
||||||
|
|
||||||
channels = 0
|
|
||||||
"""Number of channels (`int`)"""
|
|
||||||
|
|
||||||
codec_type = u""
|
|
||||||
"""Name of the codec type of the first audio stream or
|
|
||||||
an empty string if unknown. Example: ``Windows Media Audio 9 Standard``
|
|
||||||
(:class:`mutagen.text`)
|
|
||||||
"""
|
|
||||||
|
|
||||||
codec_name = u""
|
|
||||||
"""Name and maybe version of the codec used. Example:
|
|
||||||
``Windows Media Audio 9.1`` (:class:`mutagen.text`)
|
|
||||||
"""
|
|
||||||
|
|
||||||
codec_description = u""
|
|
||||||
"""Further information on the codec used.
|
|
||||||
Example: ``64 kbps, 48 kHz, stereo 2-pass CBR`` (:class:`mutagen.text`)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.length = 0.0
|
|
||||||
self.sample_rate = 0
|
|
||||||
self.bitrate = 0
|
|
||||||
self.channels = 0
|
|
||||||
self.codec_type = u""
|
|
||||||
self.codec_name = u""
|
|
||||||
self.codec_description = u""
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
"""Returns a stream information text summary
|
|
||||||
|
|
||||||
:rtype: text
|
|
||||||
"""
|
|
||||||
|
|
||||||
s = u"ASF (%s) %d bps, %s Hz, %d channels, %.2f seconds" % (
|
|
||||||
self.codec_type or self.codec_name or u"???", self.bitrate,
|
|
||||||
self.sample_rate, self.channels, self.length)
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
class ASFTags(list, DictMixin, Metadata):
|
|
||||||
"""Dictionary containing ASF attributes."""
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
"""A list of values for the key.
|
|
||||||
|
|
||||||
This is a copy, so comment['title'].append('a title') will not
|
|
||||||
work.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
# PY3 only
|
|
||||||
if isinstance(key, slice):
|
|
||||||
return list.__getitem__(self, key)
|
|
||||||
|
|
||||||
values = [value for (k, value) in self if k == key]
|
|
||||||
if not values:
|
|
||||||
raise KeyError(key)
|
|
||||||
else:
|
|
||||||
return values
|
|
||||||
|
|
||||||
def __delitem__(self, key):
|
|
||||||
"""Delete all values associated with the key."""
|
|
||||||
|
|
||||||
# PY3 only
|
|
||||||
if isinstance(key, slice):
|
|
||||||
return list.__delitem__(self, key)
|
|
||||||
|
|
||||||
to_delete = [x for x in self if x[0] == key]
|
|
||||||
if not to_delete:
|
|
||||||
raise KeyError(key)
|
|
||||||
else:
|
|
||||||
for k in to_delete:
|
|
||||||
self.remove(k)
|
|
||||||
|
|
||||||
def __contains__(self, key):
|
|
||||||
"""Return true if the key has any values."""
|
|
||||||
for k, value in self:
|
|
||||||
if k == key:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def __setitem__(self, key, values):
|
|
||||||
"""Set a key's value or values.
|
|
||||||
|
|
||||||
Setting a value overwrites all old ones. The value may be a
|
|
||||||
list of Unicode or UTF-8 strings, or a single Unicode or UTF-8
|
|
||||||
string.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# PY3 only
|
|
||||||
if isinstance(key, slice):
|
|
||||||
return list.__setitem__(self, key, values)
|
|
||||||
|
|
||||||
if not isinstance(values, list):
|
|
||||||
values = [values]
|
|
||||||
|
|
||||||
to_append = []
|
|
||||||
for value in values:
|
|
||||||
if not isinstance(value, ASFBaseAttribute):
|
|
||||||
if isinstance(value, string_types):
|
|
||||||
value = ASFUnicodeAttribute(value)
|
|
||||||
elif PY3 and isinstance(value, bytes):
|
|
||||||
value = ASFByteArrayAttribute(value)
|
|
||||||
elif isinstance(value, bool):
|
|
||||||
value = ASFBoolAttribute(value)
|
|
||||||
elif isinstance(value, int):
|
|
||||||
value = ASFDWordAttribute(value)
|
|
||||||
elif isinstance(value, long_):
|
|
||||||
value = ASFQWordAttribute(value)
|
|
||||||
else:
|
|
||||||
raise TypeError("Invalid type %r" % type(value))
|
|
||||||
to_append.append((key, value))
|
|
||||||
|
|
||||||
try:
|
|
||||||
del(self[key])
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.extend(to_append)
|
|
||||||
|
|
||||||
def keys(self):
|
|
||||||
"""Return a sequence of all keys in the comment."""
|
|
||||||
|
|
||||||
return self and set(next(izip(*self)))
|
|
||||||
|
|
||||||
def as_dict(self):
|
|
||||||
"""Return a copy of the comment data in a real dict."""
|
|
||||||
|
|
||||||
d = {}
|
|
||||||
for key, value in self:
|
|
||||||
d.setdefault(key, []).append(value)
|
|
||||||
return d
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
"""Returns a string containing all key, value pairs.
|
|
||||||
|
|
||||||
:rtype: text
|
|
||||||
"""
|
|
||||||
|
|
||||||
return "\n".join("%s=%s" % (k, v) for k, v in self)
|
|
||||||
|
|
||||||
|
|
||||||
UNICODE = ASFUnicodeAttribute.TYPE
|
|
||||||
"""Unicode string type"""
|
|
||||||
|
|
||||||
BYTEARRAY = ASFByteArrayAttribute.TYPE
|
|
||||||
"""Byte array type"""
|
|
||||||
|
|
||||||
BOOL = ASFBoolAttribute.TYPE
|
|
||||||
"""Bool type"""
|
|
||||||
|
|
||||||
DWORD = ASFDWordAttribute.TYPE
|
|
||||||
""""DWord type (uint32)"""
|
|
||||||
|
|
||||||
QWORD = ASFQWordAttribute.TYPE
|
|
||||||
"""QWord type (uint64)"""
|
|
||||||
|
|
||||||
WORD = ASFWordAttribute.TYPE
|
|
||||||
"""Word type (uint16)"""
|
|
||||||
|
|
||||||
GUID = ASFGUIDAttribute.TYPE
|
|
||||||
"""GUID type"""
|
|
||||||
|
|
||||||
|
|
||||||
class ASF(FileType):
|
|
||||||
"""An ASF file, probably containing WMA or WMV.
|
|
||||||
|
|
||||||
:param filename: a filename to load
|
|
||||||
:raises mutagen.asf.error: In case loading fails
|
|
||||||
"""
|
|
||||||
|
|
||||||
_mimes = ["audio/x-ms-wma", "audio/x-ms-wmv", "video/x-ms-asf",
|
|
||||||
"audio/x-wma", "video/x-wmv"]
|
|
||||||
|
|
||||||
info = None
|
|
||||||
"""A `ASFInfo` instance"""
|
|
||||||
|
|
||||||
tags = None
|
|
||||||
"""A `ASFTags` instance"""
|
|
||||||
|
|
||||||
def load(self, filename):
|
|
||||||
self.filename = filename
|
|
||||||
self.info = ASFInfo()
|
|
||||||
self.tags = ASFTags()
|
|
||||||
|
|
||||||
with open(filename, "rb") as fileobj:
|
|
||||||
self._tags = {}
|
|
||||||
|
|
||||||
self._header = HeaderObject.parse_full(self, fileobj)
|
|
||||||
|
|
||||||
for guid in [ContentDescriptionObject.GUID,
|
|
||||||
ExtendedContentDescriptionObject.GUID, MetadataObject.GUID,
|
|
||||||
MetadataLibraryObject.GUID]:
|
|
||||||
self.tags.extend(self._tags.pop(guid, []))
|
|
||||||
|
|
||||||
assert not self._tags
|
|
||||||
|
|
||||||
def save(self, filename=None, padding=None):
|
|
||||||
"""Save tag changes back to the loaded file.
|
|
||||||
|
|
||||||
:param padding: A callback which returns the amount of padding to use.
|
|
||||||
See :class:`mutagen.PaddingInfo`
|
|
||||||
|
|
||||||
:raises mutagen.asf.error: In case saving fails
|
|
||||||
"""
|
|
||||||
|
|
||||||
if filename is not None and filename != self.filename:
|
|
||||||
raise ValueError("saving to another file not supported atm")
|
|
||||||
|
|
||||||
# Move attributes to the right objects
|
|
||||||
self.to_content_description = {}
|
|
||||||
self.to_extended_content_description = {}
|
|
||||||
self.to_metadata = {}
|
|
||||||
self.to_metadata_library = []
|
|
||||||
for name, value in self.tags:
|
|
||||||
library_only = (value.data_size() > 0xFFFF or value.TYPE == GUID)
|
|
||||||
can_cont_desc = value.TYPE == UNICODE
|
|
||||||
|
|
||||||
if library_only or value.language is not None:
|
|
||||||
self.to_metadata_library.append((name, value))
|
|
||||||
elif value.stream is not None:
|
|
||||||
if name not in self.to_metadata:
|
|
||||||
self.to_metadata[name] = value
|
|
||||||
else:
|
|
||||||
self.to_metadata_library.append((name, value))
|
|
||||||
elif name in ContentDescriptionObject.NAMES:
|
|
||||||
if name not in self.to_content_description and can_cont_desc:
|
|
||||||
self.to_content_description[name] = value
|
|
||||||
else:
|
|
||||||
self.to_metadata_library.append((name, value))
|
|
||||||
else:
|
|
||||||
if name not in self.to_extended_content_description:
|
|
||||||
self.to_extended_content_description[name] = value
|
|
||||||
else:
|
|
||||||
self.to_metadata_library.append((name, value))
|
|
||||||
|
|
||||||
# Add missing objects
|
|
||||||
header = self._header
|
|
||||||
if header.get_child(ContentDescriptionObject.GUID) is None:
|
|
||||||
header.objects.append(ContentDescriptionObject())
|
|
||||||
if header.get_child(ExtendedContentDescriptionObject.GUID) is None:
|
|
||||||
header.objects.append(ExtendedContentDescriptionObject())
|
|
||||||
header_ext = header.get_child(HeaderExtensionObject.GUID)
|
|
||||||
if header_ext is None:
|
|
||||||
header_ext = HeaderExtensionObject()
|
|
||||||
header.objects.append(header_ext)
|
|
||||||
if header_ext.get_child(MetadataObject.GUID) is None:
|
|
||||||
header_ext.objects.append(MetadataObject())
|
|
||||||
if header_ext.get_child(MetadataLibraryObject.GUID) is None:
|
|
||||||
header_ext.objects.append(MetadataLibraryObject())
|
|
||||||
|
|
||||||
# Render to file
|
|
||||||
with open(self.filename, "rb+") as fileobj:
|
|
||||||
old_size = header.parse_size(fileobj)[0]
|
|
||||||
data = header.render_full(self, fileobj, old_size, padding)
|
|
||||||
size = len(data)
|
|
||||||
resize_bytes(fileobj, old_size, size, 0)
|
|
||||||
fileobj.seek(0)
|
|
||||||
fileobj.write(data)
|
|
||||||
|
|
||||||
def add_tags(self):
|
|
||||||
raise ASFError
|
|
||||||
|
|
||||||
def delete(self, filename=None):
|
|
||||||
|
|
||||||
if filename is not None and filename != self.filename:
|
|
||||||
raise ValueError("saving to another file not supported atm")
|
|
||||||
|
|
||||||
self.tags.clear()
|
|
||||||
self.save(padding=lambda x: 0)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score(filename, fileobj, header):
|
|
||||||
return header.startswith(HeaderObject.GUID) * 2
|
|
||||||
|
|
||||||
Open = ASF
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -1,438 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright (C) 2005-2006 Joe Wreschnig
|
|
||||||
# Copyright (C) 2006-2007 Lukas Lalinsky
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from mutagen._compat import swap_to_string, text_type, PY2, reraise
|
|
||||||
from mutagen._util import total_ordering
|
|
||||||
|
|
||||||
from ._util import ASFError
|
|
||||||
|
|
||||||
|
|
||||||
class ASFBaseAttribute(object):
|
|
||||||
"""Generic attribute."""
|
|
||||||
|
|
||||||
TYPE = None
|
|
||||||
|
|
||||||
_TYPES = {}
|
|
||||||
|
|
||||||
value = None
|
|
||||||
"""The Python value of this attribute (type depends on the class)"""
|
|
||||||
|
|
||||||
language = None
|
|
||||||
"""Language"""
|
|
||||||
|
|
||||||
stream = None
|
|
||||||
"""Stream"""
|
|
||||||
|
|
||||||
def __init__(self, value=None, data=None, language=None,
|
|
||||||
stream=None, **kwargs):
|
|
||||||
self.language = language
|
|
||||||
self.stream = stream
|
|
||||||
if data:
|
|
||||||
self.value = self.parse(data, **kwargs)
|
|
||||||
else:
|
|
||||||
if value is None:
|
|
||||||
# we used to support not passing any args and instead assign
|
|
||||||
# them later, keep that working..
|
|
||||||
self.value = None
|
|
||||||
else:
|
|
||||||
self.value = self._validate(value)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _register(cls, other):
|
|
||||||
cls._TYPES[other.TYPE] = other
|
|
||||||
return other
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _get_type(cls, type_):
|
|
||||||
"""Raises KeyError"""
|
|
||||||
|
|
||||||
return cls._TYPES[type_]
|
|
||||||
|
|
||||||
def _validate(self, value):
|
|
||||||
"""Raises TypeError or ValueError in case the user supplied value
|
|
||||||
isn't valid.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
def data_size(self):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
name = "%s(%r" % (type(self).__name__, self.value)
|
|
||||||
if self.language:
|
|
||||||
name += ", language=%d" % self.language
|
|
||||||
if self.stream:
|
|
||||||
name += ", stream=%d" % self.stream
|
|
||||||
name += ")"
|
|
||||||
return name
|
|
||||||
|
|
||||||
def render(self, name):
|
|
||||||
name = name.encode("utf-16-le") + b"\x00\x00"
|
|
||||||
data = self._render()
|
|
||||||
return (struct.pack("<H", len(name)) + name +
|
|
||||||
struct.pack("<HH", self.TYPE, len(data)) + data)
|
|
||||||
|
|
||||||
def render_m(self, name):
|
|
||||||
name = name.encode("utf-16-le") + b"\x00\x00"
|
|
||||||
if self.TYPE == 2:
|
|
||||||
data = self._render(dword=False)
|
|
||||||
else:
|
|
||||||
data = self._render()
|
|
||||||
return (struct.pack("<HHHHI", 0, self.stream or 0, len(name),
|
|
||||||
self.TYPE, len(data)) + name + data)
|
|
||||||
|
|
||||||
def render_ml(self, name):
|
|
||||||
name = name.encode("utf-16-le") + b"\x00\x00"
|
|
||||||
if self.TYPE == 2:
|
|
||||||
data = self._render(dword=False)
|
|
||||||
else:
|
|
||||||
data = self._render()
|
|
||||||
|
|
||||||
return (struct.pack("<HHHHI", self.language or 0, self.stream or 0,
|
|
||||||
len(name), self.TYPE, len(data)) + name + data)
|
|
||||||
|
|
||||||
|
|
||||||
@ASFBaseAttribute._register
|
|
||||||
@swap_to_string
|
|
||||||
@total_ordering
|
|
||||||
class ASFUnicodeAttribute(ASFBaseAttribute):
|
|
||||||
"""Unicode string attribute.
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
ASFUnicodeAttribute(u'some text')
|
|
||||||
"""
|
|
||||||
|
|
||||||
TYPE = 0x0000
|
|
||||||
|
|
||||||
def parse(self, data):
|
|
||||||
try:
|
|
||||||
return data.decode("utf-16-le").strip("\x00")
|
|
||||||
except UnicodeDecodeError as e:
|
|
||||||
reraise(ASFError, e, sys.exc_info()[2])
|
|
||||||
|
|
||||||
def _validate(self, value):
|
|
||||||
if not isinstance(value, text_type):
|
|
||||||
if PY2:
|
|
||||||
return value.decode("utf-8")
|
|
||||||
else:
|
|
||||||
raise TypeError("%r not str" % value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
def _render(self):
|
|
||||||
return self.value.encode("utf-16-le") + b"\x00\x00"
|
|
||||||
|
|
||||||
def data_size(self):
|
|
||||||
return len(self._render())
|
|
||||||
|
|
||||||
def __bytes__(self):
|
|
||||||
return self.value.encode("utf-16-le")
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
return text_type(self) == other
|
|
||||||
|
|
||||||
def __lt__(self, other):
|
|
||||||
return text_type(self) < other
|
|
||||||
|
|
||||||
__hash__ = ASFBaseAttribute.__hash__
|
|
||||||
|
|
||||||
|
|
||||||
@ASFBaseAttribute._register
|
|
||||||
@swap_to_string
|
|
||||||
@total_ordering
|
|
||||||
class ASFByteArrayAttribute(ASFBaseAttribute):
|
|
||||||
"""Byte array attribute.
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
ASFByteArrayAttribute(b'1234')
|
|
||||||
"""
|
|
||||||
TYPE = 0x0001
|
|
||||||
|
|
||||||
def parse(self, data):
|
|
||||||
assert isinstance(data, bytes)
|
|
||||||
return data
|
|
||||||
|
|
||||||
def _render(self):
|
|
||||||
assert isinstance(self.value, bytes)
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
def _validate(self, value):
|
|
||||||
if not isinstance(value, bytes):
|
|
||||||
raise TypeError("must be bytes/str: %r" % value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
def data_size(self):
|
|
||||||
return len(self.value)
|
|
||||||
|
|
||||||
def __bytes__(self):
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return "[binary data (%d bytes)]" % len(self.value)
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
return self.value == other
|
|
||||||
|
|
||||||
def __lt__(self, other):
|
|
||||||
return self.value < other
|
|
||||||
|
|
||||||
__hash__ = ASFBaseAttribute.__hash__
|
|
||||||
|
|
||||||
|
|
||||||
@ASFBaseAttribute._register
|
|
||||||
@swap_to_string
|
|
||||||
@total_ordering
|
|
||||||
class ASFBoolAttribute(ASFBaseAttribute):
|
|
||||||
"""Bool attribute.
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
ASFBoolAttribute(True)
|
|
||||||
"""
|
|
||||||
|
|
||||||
TYPE = 0x0002
|
|
||||||
|
|
||||||
def parse(self, data, dword=True):
|
|
||||||
if dword:
|
|
||||||
return struct.unpack("<I", data)[0] == 1
|
|
||||||
else:
|
|
||||||
return struct.unpack("<H", data)[0] == 1
|
|
||||||
|
|
||||||
def _render(self, dword=True):
|
|
||||||
if dword:
|
|
||||||
return struct.pack("<I", bool(self.value))
|
|
||||||
else:
|
|
||||||
return struct.pack("<H", bool(self.value))
|
|
||||||
|
|
||||||
def _validate(self, value):
|
|
||||||
return bool(value)
|
|
||||||
|
|
||||||
def data_size(self):
|
|
||||||
return 4
|
|
||||||
|
|
||||||
def __bool__(self):
|
|
||||||
return bool(self.value)
|
|
||||||
|
|
||||||
def __bytes__(self):
|
|
||||||
return text_type(self.value).encode('utf-8')
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return text_type(self.value)
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
return bool(self.value) == other
|
|
||||||
|
|
||||||
def __lt__(self, other):
|
|
||||||
return bool(self.value) < other
|
|
||||||
|
|
||||||
__hash__ = ASFBaseAttribute.__hash__
|
|
||||||
|
|
||||||
|
|
||||||
@ASFBaseAttribute._register
|
|
||||||
@swap_to_string
|
|
||||||
@total_ordering
|
|
||||||
class ASFDWordAttribute(ASFBaseAttribute):
|
|
||||||
"""DWORD attribute.
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
ASFDWordAttribute(42)
|
|
||||||
"""
|
|
||||||
|
|
||||||
TYPE = 0x0003
|
|
||||||
|
|
||||||
def parse(self, data):
|
|
||||||
return struct.unpack("<L", data)[0]
|
|
||||||
|
|
||||||
def _render(self):
|
|
||||||
return struct.pack("<L", self.value)
|
|
||||||
|
|
||||||
def _validate(self, value):
|
|
||||||
value = int(value)
|
|
||||||
if not 0 <= value <= 2 ** 32 - 1:
|
|
||||||
raise ValueError("Out of range")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def data_size(self):
|
|
||||||
return 4
|
|
||||||
|
|
||||||
def __int__(self):
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
def __bytes__(self):
|
|
||||||
return text_type(self.value).encode('utf-8')
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return text_type(self.value)
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
return int(self.value) == other
|
|
||||||
|
|
||||||
def __lt__(self, other):
|
|
||||||
return int(self.value) < other
|
|
||||||
|
|
||||||
__hash__ = ASFBaseAttribute.__hash__
|
|
||||||
|
|
||||||
|
|
||||||
@ASFBaseAttribute._register
|
|
||||||
@swap_to_string
|
|
||||||
@total_ordering
|
|
||||||
class ASFQWordAttribute(ASFBaseAttribute):
|
|
||||||
"""QWORD attribute.
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
ASFQWordAttribute(42)
|
|
||||||
"""
|
|
||||||
|
|
||||||
TYPE = 0x0004
|
|
||||||
|
|
||||||
def parse(self, data):
|
|
||||||
return struct.unpack("<Q", data)[0]
|
|
||||||
|
|
||||||
def _render(self):
|
|
||||||
return struct.pack("<Q", self.value)
|
|
||||||
|
|
||||||
def _validate(self, value):
|
|
||||||
value = int(value)
|
|
||||||
if not 0 <= value <= 2 ** 64 - 1:
|
|
||||||
raise ValueError("Out of range")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def data_size(self):
|
|
||||||
return 8
|
|
||||||
|
|
||||||
def __int__(self):
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
def __bytes__(self):
|
|
||||||
return text_type(self.value).encode('utf-8')
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return text_type(self.value)
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
return int(self.value) == other
|
|
||||||
|
|
||||||
def __lt__(self, other):
|
|
||||||
return int(self.value) < other
|
|
||||||
|
|
||||||
__hash__ = ASFBaseAttribute.__hash__
|
|
||||||
|
|
||||||
|
|
||||||
@ASFBaseAttribute._register
|
|
||||||
@swap_to_string
|
|
||||||
@total_ordering
|
|
||||||
class ASFWordAttribute(ASFBaseAttribute):
|
|
||||||
"""WORD attribute.
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
ASFWordAttribute(42)
|
|
||||||
"""
|
|
||||||
|
|
||||||
TYPE = 0x0005
|
|
||||||
|
|
||||||
def parse(self, data):
|
|
||||||
return struct.unpack("<H", data)[0]
|
|
||||||
|
|
||||||
def _render(self):
|
|
||||||
return struct.pack("<H", self.value)
|
|
||||||
|
|
||||||
def _validate(self, value):
|
|
||||||
value = int(value)
|
|
||||||
if not 0 <= value <= 2 ** 16 - 1:
|
|
||||||
raise ValueError("Out of range")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def data_size(self):
|
|
||||||
return 2
|
|
||||||
|
|
||||||
def __int__(self):
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
def __bytes__(self):
|
|
||||||
return text_type(self.value).encode('utf-8')
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return text_type(self.value)
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
return int(self.value) == other
|
|
||||||
|
|
||||||
def __lt__(self, other):
|
|
||||||
return int(self.value) < other
|
|
||||||
|
|
||||||
__hash__ = ASFBaseAttribute.__hash__
|
|
||||||
|
|
||||||
|
|
||||||
@ASFBaseAttribute._register
|
|
||||||
@swap_to_string
|
|
||||||
@total_ordering
|
|
||||||
class ASFGUIDAttribute(ASFBaseAttribute):
|
|
||||||
"""GUID attribute."""
|
|
||||||
|
|
||||||
TYPE = 0x0006
|
|
||||||
|
|
||||||
def parse(self, data):
|
|
||||||
assert isinstance(data, bytes)
|
|
||||||
return data
|
|
||||||
|
|
||||||
def _render(self):
|
|
||||||
assert isinstance(self.value, bytes)
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
def _validate(self, value):
|
|
||||||
if not isinstance(value, bytes):
|
|
||||||
raise TypeError("must be bytes/str: %r" % value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
def data_size(self):
|
|
||||||
return len(self.value)
|
|
||||||
|
|
||||||
def __bytes__(self):
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return repr(self.value)
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
return self.value == other
|
|
||||||
|
|
||||||
def __lt__(self, other):
|
|
||||||
return self.value < other
|
|
||||||
|
|
||||||
__hash__ = ASFBaseAttribute.__hash__
|
|
||||||
|
|
||||||
|
|
||||||
def ASFValue(value, kind, **kwargs):
|
|
||||||
"""Create a tag value of a specific kind.
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
ASFValue(u"My Value", UNICODE)
|
|
||||||
|
|
||||||
:rtype: ASFBaseAttribute
|
|
||||||
:raises TypeError: in case a wrong type was passed
|
|
||||||
:raises ValueError: in case the value can't be be represented as ASFValue.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
attr_type = ASFBaseAttribute._get_type(kind)
|
|
||||||
except KeyError:
|
|
||||||
raise ValueError("Unknown value type %r" % kind)
|
|
||||||
else:
|
|
||||||
return attr_type(value=value, **kwargs)
|
|
|
@ -1,437 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright (C) 2005-2006 Joe Wreschnig
|
|
||||||
# Copyright (C) 2006-2007 Lukas Lalinsky
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from mutagen._util import cdata, get_size
|
|
||||||
from mutagen._compat import text_type, xrange, izip
|
|
||||||
from mutagen._tags import PaddingInfo
|
|
||||||
|
|
||||||
from ._util import guid2bytes, bytes2guid, CODECS, ASFError, ASFHeaderError
|
|
||||||
from ._attrs import ASFBaseAttribute, ASFUnicodeAttribute
|
|
||||||
|
|
||||||
|
|
||||||
class BaseObject(object):
|
|
||||||
"""Base ASF object."""
|
|
||||||
|
|
||||||
GUID = None
|
|
||||||
_TYPES = {}
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.objects = []
|
|
||||||
self.data = b""
|
|
||||||
|
|
||||||
def parse(self, asf, data):
|
|
||||||
self.data = data
|
|
||||||
|
|
||||||
def render(self, asf):
|
|
||||||
data = self.GUID + struct.pack("<Q", len(self.data) + 24) + self.data
|
|
||||||
return data
|
|
||||||
|
|
||||||
def get_child(self, guid):
|
|
||||||
for obj in self.objects:
|
|
||||||
if obj.GUID == guid:
|
|
||||||
return obj
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _register(cls, other):
|
|
||||||
cls._TYPES[other.GUID] = other
|
|
||||||
return other
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _get_object(cls, guid):
|
|
||||||
if guid in cls._TYPES:
|
|
||||||
return cls._TYPES[guid]()
|
|
||||||
else:
|
|
||||||
return UnknownObject(guid)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return "<%s GUID=%s objects=%r>" % (
|
|
||||||
type(self).__name__, bytes2guid(self.GUID), self.objects)
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
l = []
|
|
||||||
l.append("%s(%s)" % (type(self).__name__, bytes2guid(self.GUID)))
|
|
||||||
for o in self.objects:
|
|
||||||
for e in o.pprint().splitlines():
|
|
||||||
l.append(" " + e)
|
|
||||||
return "\n".join(l)
|
|
||||||
|
|
||||||
|
|
||||||
class UnknownObject(BaseObject):
|
|
||||||
"""Unknown ASF object."""
|
|
||||||
|
|
||||||
def __init__(self, guid):
|
|
||||||
super(UnknownObject, self).__init__()
|
|
||||||
assert isinstance(guid, bytes)
|
|
||||||
self.GUID = guid
|
|
||||||
|
|
||||||
|
|
||||||
@BaseObject._register
|
|
||||||
class HeaderObject(BaseObject):
|
|
||||||
"""ASF header."""
|
|
||||||
|
|
||||||
GUID = guid2bytes("75B22630-668E-11CF-A6D9-00AA0062CE6C")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def parse_full(cls, asf, fileobj):
|
|
||||||
"""Raises ASFHeaderError"""
|
|
||||||
|
|
||||||
header = cls()
|
|
||||||
|
|
||||||
size, num_objects = cls.parse_size(fileobj)
|
|
||||||
for i in xrange(num_objects):
|
|
||||||
guid, size = struct.unpack("<16sQ", fileobj.read(24))
|
|
||||||
obj = BaseObject._get_object(guid)
|
|
||||||
data = fileobj.read(size - 24)
|
|
||||||
obj.parse(asf, data)
|
|
||||||
header.objects.append(obj)
|
|
||||||
|
|
||||||
return header
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def parse_size(cls, fileobj):
|
|
||||||
"""Returns (size, num_objects)
|
|
||||||
|
|
||||||
Raises ASFHeaderError
|
|
||||||
"""
|
|
||||||
|
|
||||||
header = fileobj.read(30)
|
|
||||||
if len(header) != 30 or header[:16] != HeaderObject.GUID:
|
|
||||||
raise ASFHeaderError("Not an ASF file.")
|
|
||||||
|
|
||||||
return struct.unpack("<QL", header[16:28])
|
|
||||||
|
|
||||||
def render_full(self, asf, fileobj, available, padding_func):
|
|
||||||
# Render everything except padding
|
|
||||||
num_objects = 0
|
|
||||||
data = bytearray()
|
|
||||||
for obj in self.objects:
|
|
||||||
if obj.GUID == PaddingObject.GUID:
|
|
||||||
continue
|
|
||||||
data += obj.render(asf)
|
|
||||||
num_objects += 1
|
|
||||||
|
|
||||||
# calculate how much space we need at least
|
|
||||||
padding_obj = PaddingObject()
|
|
||||||
header_size = len(HeaderObject.GUID) + 14
|
|
||||||
padding_overhead = len(padding_obj.render(asf))
|
|
||||||
needed_size = len(data) + header_size + padding_overhead
|
|
||||||
|
|
||||||
# ask the user for padding adjustments
|
|
||||||
file_size = get_size(fileobj)
|
|
||||||
content_size = file_size - available
|
|
||||||
assert content_size >= 0
|
|
||||||
info = PaddingInfo(available - needed_size, content_size)
|
|
||||||
|
|
||||||
# add padding
|
|
||||||
padding = info._get_padding(padding_func)
|
|
||||||
padding_obj.parse(asf, b"\x00" * padding)
|
|
||||||
data += padding_obj.render(asf)
|
|
||||||
num_objects += 1
|
|
||||||
|
|
||||||
data = (HeaderObject.GUID +
|
|
||||||
struct.pack("<QL", len(data) + 30, num_objects) +
|
|
||||||
b"\x01\x02" + data)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def parse(self, asf, data):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def render(self, asf):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
@BaseObject._register
|
|
||||||
class ContentDescriptionObject(BaseObject):
|
|
||||||
"""Content description."""
|
|
||||||
|
|
||||||
GUID = guid2bytes("75B22633-668E-11CF-A6D9-00AA0062CE6C")
|
|
||||||
|
|
||||||
NAMES = [
|
|
||||||
u"Title",
|
|
||||||
u"Author",
|
|
||||||
u"Copyright",
|
|
||||||
u"Description",
|
|
||||||
u"Rating",
|
|
||||||
]
|
|
||||||
|
|
||||||
def parse(self, asf, data):
|
|
||||||
super(ContentDescriptionObject, self).parse(asf, data)
|
|
||||||
lengths = struct.unpack("<HHHHH", data[:10])
|
|
||||||
texts = []
|
|
||||||
pos = 10
|
|
||||||
for length in lengths:
|
|
||||||
end = pos + length
|
|
||||||
if length > 0:
|
|
||||||
texts.append(data[pos:end].decode("utf-16-le").strip(u"\x00"))
|
|
||||||
else:
|
|
||||||
texts.append(None)
|
|
||||||
pos = end
|
|
||||||
|
|
||||||
for key, value in izip(self.NAMES, texts):
|
|
||||||
if value is not None:
|
|
||||||
value = ASFUnicodeAttribute(value=value)
|
|
||||||
asf._tags.setdefault(self.GUID, []).append((key, value))
|
|
||||||
|
|
||||||
def render(self, asf):
|
|
||||||
def render_text(name):
|
|
||||||
value = asf.to_content_description.get(name)
|
|
||||||
if value is not None:
|
|
||||||
return text_type(value).encode("utf-16-le") + b"\x00\x00"
|
|
||||||
else:
|
|
||||||
return b""
|
|
||||||
|
|
||||||
texts = [render_text(x) for x in self.NAMES]
|
|
||||||
data = struct.pack("<HHHHH", *map(len, texts)) + b"".join(texts)
|
|
||||||
return self.GUID + struct.pack("<Q", 24 + len(data)) + data
|
|
||||||
|
|
||||||
|
|
||||||
@BaseObject._register
|
|
||||||
class ExtendedContentDescriptionObject(BaseObject):
|
|
||||||
"""Extended content description."""
|
|
||||||
|
|
||||||
GUID = guid2bytes("D2D0A440-E307-11D2-97F0-00A0C95EA850")
|
|
||||||
|
|
||||||
def parse(self, asf, data):
|
|
||||||
super(ExtendedContentDescriptionObject, self).parse(asf, data)
|
|
||||||
num_attributes, = struct.unpack("<H", data[0:2])
|
|
||||||
pos = 2
|
|
||||||
for i in xrange(num_attributes):
|
|
||||||
name_length, = struct.unpack("<H", data[pos:pos + 2])
|
|
||||||
pos += 2
|
|
||||||
name = data[pos:pos + name_length]
|
|
||||||
name = name.decode("utf-16-le").strip("\x00")
|
|
||||||
pos += name_length
|
|
||||||
value_type, value_length = struct.unpack("<HH", data[pos:pos + 4])
|
|
||||||
pos += 4
|
|
||||||
value = data[pos:pos + value_length]
|
|
||||||
pos += value_length
|
|
||||||
attr = ASFBaseAttribute._get_type(value_type)(data=value)
|
|
||||||
asf._tags.setdefault(self.GUID, []).append((name, attr))
|
|
||||||
|
|
||||||
def render(self, asf):
|
|
||||||
attrs = asf.to_extended_content_description.items()
|
|
||||||
data = b"".join(attr.render(name) for (name, attr) in attrs)
|
|
||||||
data = struct.pack("<QH", 26 + len(data), len(attrs)) + data
|
|
||||||
return self.GUID + data
|
|
||||||
|
|
||||||
|
|
||||||
@BaseObject._register
|
|
||||||
class FilePropertiesObject(BaseObject):
|
|
||||||
"""File properties."""
|
|
||||||
|
|
||||||
GUID = guid2bytes("8CABDCA1-A947-11CF-8EE4-00C00C205365")
|
|
||||||
|
|
||||||
def parse(self, asf, data):
|
|
||||||
super(FilePropertiesObject, self).parse(asf, data)
|
|
||||||
length, _, preroll = struct.unpack("<QQQ", data[40:64])
|
|
||||||
# there are files where preroll is larger than length, limit to >= 0
|
|
||||||
asf.info.length = max((length / 10000000.0) - (preroll / 1000.0), 0.0)
|
|
||||||
|
|
||||||
|
|
||||||
@BaseObject._register
|
|
||||||
class StreamPropertiesObject(BaseObject):
|
|
||||||
"""Stream properties."""
|
|
||||||
|
|
||||||
GUID = guid2bytes("B7DC0791-A9B7-11CF-8EE6-00C00C205365")
|
|
||||||
|
|
||||||
def parse(self, asf, data):
|
|
||||||
super(StreamPropertiesObject, self).parse(asf, data)
|
|
||||||
channels, sample_rate, bitrate = struct.unpack("<HII", data[56:66])
|
|
||||||
asf.info.channels = channels
|
|
||||||
asf.info.sample_rate = sample_rate
|
|
||||||
asf.info.bitrate = bitrate * 8
|
|
||||||
|
|
||||||
|
|
||||||
@BaseObject._register
|
|
||||||
class CodecListObject(BaseObject):
|
|
||||||
"""Codec List"""
|
|
||||||
|
|
||||||
GUID = guid2bytes("86D15240-311D-11D0-A3A4-00A0C90348F6")
|
|
||||||
|
|
||||||
def _parse_entry(self, data, offset):
|
|
||||||
"""can raise cdata.error"""
|
|
||||||
|
|
||||||
type_, offset = cdata.uint16_le_from(data, offset)
|
|
||||||
|
|
||||||
units, offset = cdata.uint16_le_from(data, offset)
|
|
||||||
# utf-16 code units, not characters..
|
|
||||||
next_offset = offset + units * 2
|
|
||||||
try:
|
|
||||||
name = data[offset:next_offset].decode("utf-16-le").strip("\x00")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
name = u""
|
|
||||||
offset = next_offset
|
|
||||||
|
|
||||||
units, offset = cdata.uint16_le_from(data, offset)
|
|
||||||
next_offset = offset + units * 2
|
|
||||||
try:
|
|
||||||
desc = data[offset:next_offset].decode("utf-16-le").strip("\x00")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
desc = u""
|
|
||||||
offset = next_offset
|
|
||||||
|
|
||||||
bytes_, offset = cdata.uint16_le_from(data, offset)
|
|
||||||
next_offset = offset + bytes_
|
|
||||||
codec = u""
|
|
||||||
if bytes_ == 2:
|
|
||||||
codec_id = cdata.uint16_le_from(data, offset)[0]
|
|
||||||
if codec_id in CODECS:
|
|
||||||
codec = CODECS[codec_id]
|
|
||||||
offset = next_offset
|
|
||||||
|
|
||||||
return offset, type_, name, desc, codec
|
|
||||||
|
|
||||||
def parse(self, asf, data):
|
|
||||||
super(CodecListObject, self).parse(asf, data)
|
|
||||||
|
|
||||||
offset = 16
|
|
||||||
count, offset = cdata.uint32_le_from(data, offset)
|
|
||||||
for i in xrange(count):
|
|
||||||
try:
|
|
||||||
offset, type_, name, desc, codec = \
|
|
||||||
self._parse_entry(data, offset)
|
|
||||||
except cdata.error:
|
|
||||||
raise ASFError("invalid codec entry")
|
|
||||||
|
|
||||||
# go with the first audio entry
|
|
||||||
if type_ == 2:
|
|
||||||
name = name.strip()
|
|
||||||
desc = desc.strip()
|
|
||||||
asf.info.codec_type = codec
|
|
||||||
asf.info.codec_name = name
|
|
||||||
asf.info.codec_description = desc
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
@BaseObject._register
|
|
||||||
class PaddingObject(BaseObject):
|
|
||||||
"""Padding object"""
|
|
||||||
|
|
||||||
GUID = guid2bytes("1806D474-CADF-4509-A4BA-9AABCB96AAE8")
|
|
||||||
|
|
||||||
|
|
||||||
@BaseObject._register
|
|
||||||
class StreamBitratePropertiesObject(BaseObject):
|
|
||||||
"""Stream bitrate properties"""
|
|
||||||
|
|
||||||
GUID = guid2bytes("7BF875CE-468D-11D1-8D82-006097C9A2B2")
|
|
||||||
|
|
||||||
|
|
||||||
@BaseObject._register
|
|
||||||
class ContentEncryptionObject(BaseObject):
|
|
||||||
"""Content encryption"""
|
|
||||||
|
|
||||||
GUID = guid2bytes("2211B3FB-BD23-11D2-B4B7-00A0C955FC6E")
|
|
||||||
|
|
||||||
|
|
||||||
@BaseObject._register
|
|
||||||
class ExtendedContentEncryptionObject(BaseObject):
|
|
||||||
"""Extended content encryption"""
|
|
||||||
|
|
||||||
GUID = guid2bytes("298AE614-2622-4C17-B935-DAE07EE9289C")
|
|
||||||
|
|
||||||
|
|
||||||
@BaseObject._register
|
|
||||||
class HeaderExtensionObject(BaseObject):
|
|
||||||
"""Header extension."""
|
|
||||||
|
|
||||||
GUID = guid2bytes("5FBF03B5-A92E-11CF-8EE3-00C00C205365")
|
|
||||||
|
|
||||||
def parse(self, asf, data):
|
|
||||||
super(HeaderExtensionObject, self).parse(asf, data)
|
|
||||||
datasize, = struct.unpack("<I", data[18:22])
|
|
||||||
datapos = 0
|
|
||||||
while datapos < datasize:
|
|
||||||
guid, size = struct.unpack(
|
|
||||||
"<16sQ", data[22 + datapos:22 + datapos + 24])
|
|
||||||
obj = BaseObject._get_object(guid)
|
|
||||||
obj.parse(asf, data[22 + datapos + 24:22 + datapos + size])
|
|
||||||
self.objects.append(obj)
|
|
||||||
datapos += size
|
|
||||||
|
|
||||||
def render(self, asf):
|
|
||||||
data = bytearray()
|
|
||||||
for obj in self.objects:
|
|
||||||
# some files have the padding in the extension header, but we
|
|
||||||
# want to add it at the end of the top level header. Just
|
|
||||||
# skip padding at this level.
|
|
||||||
if obj.GUID == PaddingObject.GUID:
|
|
||||||
continue
|
|
||||||
data += obj.render(asf)
|
|
||||||
return (self.GUID + struct.pack("<Q", 24 + 16 + 6 + len(data)) +
|
|
||||||
b"\x11\xD2\xD3\xAB\xBA\xA9\xcf\x11" +
|
|
||||||
b"\x8E\xE6\x00\xC0\x0C\x20\x53\x65" +
|
|
||||||
b"\x06\x00" + struct.pack("<I", len(data)) + data)
|
|
||||||
|
|
||||||
|
|
||||||
@BaseObject._register
|
|
||||||
class MetadataObject(BaseObject):
|
|
||||||
"""Metadata description."""
|
|
||||||
|
|
||||||
GUID = guid2bytes("C5F8CBEA-5BAF-4877-8467-AA8C44FA4CCA")
|
|
||||||
|
|
||||||
def parse(self, asf, data):
|
|
||||||
super(MetadataObject, self).parse(asf, data)
|
|
||||||
num_attributes, = struct.unpack("<H", data[0:2])
|
|
||||||
pos = 2
|
|
||||||
for i in xrange(num_attributes):
|
|
||||||
(reserved, stream, name_length, value_type,
|
|
||||||
value_length) = struct.unpack("<HHHHI", data[pos:pos + 12])
|
|
||||||
pos += 12
|
|
||||||
name = data[pos:pos + name_length]
|
|
||||||
name = name.decode("utf-16-le").strip("\x00")
|
|
||||||
pos += name_length
|
|
||||||
value = data[pos:pos + value_length]
|
|
||||||
pos += value_length
|
|
||||||
args = {'data': value, 'stream': stream}
|
|
||||||
if value_type == 2:
|
|
||||||
args['dword'] = False
|
|
||||||
attr = ASFBaseAttribute._get_type(value_type)(**args)
|
|
||||||
asf._tags.setdefault(self.GUID, []).append((name, attr))
|
|
||||||
|
|
||||||
def render(self, asf):
|
|
||||||
attrs = asf.to_metadata.items()
|
|
||||||
data = b"".join([attr.render_m(name) for (name, attr) in attrs])
|
|
||||||
return (self.GUID + struct.pack("<QH", 26 + len(data), len(attrs)) +
|
|
||||||
data)
|
|
||||||
|
|
||||||
|
|
||||||
@BaseObject._register
|
|
||||||
class MetadataLibraryObject(BaseObject):
|
|
||||||
"""Metadata library description."""
|
|
||||||
|
|
||||||
GUID = guid2bytes("44231C94-9498-49D1-A141-1D134E457054")
|
|
||||||
|
|
||||||
def parse(self, asf, data):
|
|
||||||
super(MetadataLibraryObject, self).parse(asf, data)
|
|
||||||
num_attributes, = struct.unpack("<H", data[0:2])
|
|
||||||
pos = 2
|
|
||||||
for i in xrange(num_attributes):
|
|
||||||
(language, stream, name_length, value_type,
|
|
||||||
value_length) = struct.unpack("<HHHHI", data[pos:pos + 12])
|
|
||||||
pos += 12
|
|
||||||
name = data[pos:pos + name_length]
|
|
||||||
name = name.decode("utf-16-le").strip("\x00")
|
|
||||||
pos += name_length
|
|
||||||
value = data[pos:pos + value_length]
|
|
||||||
pos += value_length
|
|
||||||
args = {'data': value, 'language': language, 'stream': stream}
|
|
||||||
if value_type == 2:
|
|
||||||
args['dword'] = False
|
|
||||||
attr = ASFBaseAttribute._get_type(value_type)(**args)
|
|
||||||
asf._tags.setdefault(self.GUID, []).append((name, attr))
|
|
||||||
|
|
||||||
def render(self, asf):
|
|
||||||
attrs = asf.to_metadata_library
|
|
||||||
data = b"".join([attr.render_ml(name) for (name, attr) in attrs])
|
|
||||||
return (self.GUID + struct.pack("<QH", 26 + len(data), len(attrs)) +
|
|
||||||
data)
|
|
|
@ -1,315 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright (C) 2005-2006 Joe Wreschnig
|
|
||||||
# Copyright (C) 2006-2007 Lukas Lalinsky
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from mutagen._util import MutagenError
|
|
||||||
|
|
||||||
|
|
||||||
class error(IOError, MutagenError):
|
|
||||||
"""Error raised by :mod:`mutagen.asf`"""
|
|
||||||
|
|
||||||
|
|
||||||
class ASFError(error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ASFHeaderError(error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def guid2bytes(s):
|
|
||||||
"""Converts a GUID to the serialized bytes representation"""
|
|
||||||
|
|
||||||
assert isinstance(s, str)
|
|
||||||
assert len(s) == 36
|
|
||||||
|
|
||||||
p = struct.pack
|
|
||||||
return b"".join([
|
|
||||||
p("<IHH", int(s[:8], 16), int(s[9:13], 16), int(s[14:18], 16)),
|
|
||||||
p(">H", int(s[19:23], 16)),
|
|
||||||
p(">Q", int(s[24:], 16))[2:],
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
def bytes2guid(s):
|
|
||||||
"""Converts a serialized GUID to a text GUID"""
|
|
||||||
|
|
||||||
assert isinstance(s, bytes)
|
|
||||||
|
|
||||||
u = struct.unpack
|
|
||||||
v = []
|
|
||||||
v.extend(u("<IHH", s[:8]))
|
|
||||||
v.extend(u(">HQ", s[8:10] + b"\x00\x00" + s[10:]))
|
|
||||||
return "%08X-%04X-%04X-%04X-%012X" % tuple(v)
|
|
||||||
|
|
||||||
|
|
||||||
# Names from http://windows.microsoft.com/en-za/windows7/c00d10d1-[0-9A-F]{1,4}
|
|
||||||
CODECS = {
|
|
||||||
0x0000: u"Unknown Wave Format",
|
|
||||||
0x0001: u"Microsoft PCM Format",
|
|
||||||
0x0002: u"Microsoft ADPCM Format",
|
|
||||||
0x0003: u"IEEE Float",
|
|
||||||
0x0004: u"Compaq Computer VSELP",
|
|
||||||
0x0005: u"IBM CVSD",
|
|
||||||
0x0006: u"Microsoft CCITT A-Law",
|
|
||||||
0x0007: u"Microsoft CCITT u-Law",
|
|
||||||
0x0008: u"Microsoft DTS",
|
|
||||||
0x0009: u"Microsoft DRM",
|
|
||||||
0x000A: u"Windows Media Audio 9 Voice",
|
|
||||||
0x000B: u"Windows Media Audio 10 Voice",
|
|
||||||
0x000C: u"OGG Vorbis",
|
|
||||||
0x000D: u"FLAC",
|
|
||||||
0x000E: u"MOT AMR",
|
|
||||||
0x000F: u"Nice Systems IMBE",
|
|
||||||
0x0010: u"OKI ADPCM",
|
|
||||||
0x0011: u"Intel IMA ADPCM",
|
|
||||||
0x0012: u"Videologic MediaSpace ADPCM",
|
|
||||||
0x0013: u"Sierra Semiconductor ADPCM",
|
|
||||||
0x0014: u"Antex Electronics G.723 ADPCM",
|
|
||||||
0x0015: u"DSP Solutions DIGISTD",
|
|
||||||
0x0016: u"DSP Solutions DIGIFIX",
|
|
||||||
0x0017: u"Dialogic OKI ADPCM",
|
|
||||||
0x0018: u"MediaVision ADPCM",
|
|
||||||
0x0019: u"Hewlett-Packard CU codec",
|
|
||||||
0x001A: u"Hewlett-Packard Dynamic Voice",
|
|
||||||
0x0020: u"Yamaha ADPCM",
|
|
||||||
0x0021: u"Speech Compression SONARC",
|
|
||||||
0x0022: u"DSP Group True Speech",
|
|
||||||
0x0023: u"Echo Speech EchoSC1",
|
|
||||||
0x0024: u"Ahead Inc. Audiofile AF36",
|
|
||||||
0x0025: u"Audio Processing Technology APTX",
|
|
||||||
0x0026: u"Ahead Inc. AudioFile AF10",
|
|
||||||
0x0027: u"Aculab Prosody 1612",
|
|
||||||
0x0028: u"Merging Technologies S.A. LRC",
|
|
||||||
0x0030: u"Dolby Labs AC2",
|
|
||||||
0x0031: u"Microsoft GSM 6.10",
|
|
||||||
0x0032: u"Microsoft MSNAudio",
|
|
||||||
0x0033: u"Antex Electronics ADPCME",
|
|
||||||
0x0034: u"Control Resources VQLPC",
|
|
||||||
0x0035: u"DSP Solutions Digireal",
|
|
||||||
0x0036: u"DSP Solutions DigiADPCM",
|
|
||||||
0x0037: u"Control Resources CR10",
|
|
||||||
0x0038: u"Natural MicroSystems VBXADPCM",
|
|
||||||
0x0039: u"Crystal Semiconductor IMA ADPCM",
|
|
||||||
0x003A: u"Echo Speech EchoSC3",
|
|
||||||
0x003B: u"Rockwell ADPCM",
|
|
||||||
0x003C: u"Rockwell DigiTalk",
|
|
||||||
0x003D: u"Xebec Multimedia Solutions",
|
|
||||||
0x0040: u"Antex Electronics G.721 ADPCM",
|
|
||||||
0x0041: u"Antex Electronics G.728 CELP",
|
|
||||||
0x0042: u"Intel G.723",
|
|
||||||
0x0043: u"Intel G.723.1",
|
|
||||||
0x0044: u"Intel G.729 Audio",
|
|
||||||
0x0045: u"Sharp G.726 Audio",
|
|
||||||
0x0050: u"Microsoft MPEG-1",
|
|
||||||
0x0052: u"InSoft RT24",
|
|
||||||
0x0053: u"InSoft PAC",
|
|
||||||
0x0055: u"MP3 - MPEG Layer III",
|
|
||||||
0x0059: u"Lucent G.723",
|
|
||||||
0x0060: u"Cirrus Logic",
|
|
||||||
0x0061: u"ESS Technology ESPCM",
|
|
||||||
0x0062: u"Voxware File-Mode",
|
|
||||||
0x0063: u"Canopus Atrac",
|
|
||||||
0x0064: u"APICOM G.726 ADPCM",
|
|
||||||
0x0065: u"APICOM G.722 ADPCM",
|
|
||||||
0x0066: u"Microsoft DSAT",
|
|
||||||
0x0067: u"Microsoft DSAT Display",
|
|
||||||
0x0069: u"Voxware Byte Aligned",
|
|
||||||
0x0070: u"Voxware AC8",
|
|
||||||
0x0071: u"Voxware AC10",
|
|
||||||
0x0072: u"Voxware AC16",
|
|
||||||
0x0073: u"Voxware AC20",
|
|
||||||
0x0074: u"Voxware RT24 MetaVoice",
|
|
||||||
0x0075: u"Voxware RT29 MetaSound",
|
|
||||||
0x0076: u"Voxware RT29HW",
|
|
||||||
0x0077: u"Voxware VR12",
|
|
||||||
0x0078: u"Voxware VR18",
|
|
||||||
0x0079: u"Voxware TQ40",
|
|
||||||
0x007A: u"Voxware SC3",
|
|
||||||
0x007B: u"Voxware SC3",
|
|
||||||
0x0080: u"Softsound",
|
|
||||||
0x0081: u"Voxware TQ60",
|
|
||||||
0x0082: u"Microsoft MSRT24",
|
|
||||||
0x0083: u"AT&T Labs G.729A",
|
|
||||||
0x0084: u"Motion Pixels MVI MV12",
|
|
||||||
0x0085: u"DataFusion Systems G.726",
|
|
||||||
0x0086: u"DataFusion Systems GSM610",
|
|
||||||
0x0088: u"Iterated Systems ISIAudio",
|
|
||||||
0x0089: u"Onlive",
|
|
||||||
0x008A: u"Multitude FT SX20",
|
|
||||||
0x008B: u"Infocom ITS ACM G.721",
|
|
||||||
0x008C: u"Convedia G.729",
|
|
||||||
0x008D: u"Congruency Audio",
|
|
||||||
0x0091: u"Siemens Business Communications SBC24",
|
|
||||||
0x0092: u"Sonic Foundry Dolby AC3 SPDIF",
|
|
||||||
0x0093: u"MediaSonic G.723",
|
|
||||||
0x0094: u"Aculab Prosody 8KBPS",
|
|
||||||
0x0097: u"ZyXEL ADPCM",
|
|
||||||
0x0098: u"Philips LPCBB",
|
|
||||||
0x0099: u"Studer Professional Audio AG Packed",
|
|
||||||
0x00A0: u"Malden Electronics PHONYTALK",
|
|
||||||
0x00A1: u"Racal Recorder GSM",
|
|
||||||
0x00A2: u"Racal Recorder G720.a",
|
|
||||||
0x00A3: u"Racal Recorder G723.1",
|
|
||||||
0x00A4: u"Racal Recorder Tetra ACELP",
|
|
||||||
0x00B0: u"NEC AAC",
|
|
||||||
0x00FF: u"CoreAAC Audio",
|
|
||||||
0x0100: u"Rhetorex ADPCM",
|
|
||||||
0x0101: u"BeCubed Software IRAT",
|
|
||||||
0x0111: u"Vivo G.723",
|
|
||||||
0x0112: u"Vivo Siren",
|
|
||||||
0x0120: u"Philips CELP",
|
|
||||||
0x0121: u"Philips Grundig",
|
|
||||||
0x0123: u"Digital G.723",
|
|
||||||
0x0125: u"Sanyo ADPCM",
|
|
||||||
0x0130: u"Sipro Lab Telecom ACELP.net",
|
|
||||||
0x0131: u"Sipro Lab Telecom ACELP.4800",
|
|
||||||
0x0132: u"Sipro Lab Telecom ACELP.8V3",
|
|
||||||
0x0133: u"Sipro Lab Telecom ACELP.G.729",
|
|
||||||
0x0134: u"Sipro Lab Telecom ACELP.G.729A",
|
|
||||||
0x0135: u"Sipro Lab Telecom ACELP.KELVIN",
|
|
||||||
0x0136: u"VoiceAge AMR",
|
|
||||||
0x0140: u"Dictaphone G.726 ADPCM",
|
|
||||||
0x0141: u"Dictaphone CELP68",
|
|
||||||
0x0142: u"Dictaphone CELP54",
|
|
||||||
0x0150: u"Qualcomm PUREVOICE",
|
|
||||||
0x0151: u"Qualcomm HALFRATE",
|
|
||||||
0x0155: u"Ring Zero Systems TUBGSM",
|
|
||||||
0x0160: u"Windows Media Audio Standard",
|
|
||||||
0x0161: u"Windows Media Audio 9 Standard",
|
|
||||||
0x0162: u"Windows Media Audio 9 Professional",
|
|
||||||
0x0163: u"Windows Media Audio 9 Lossless",
|
|
||||||
0x0164: u"Windows Media Audio Pro over SPDIF",
|
|
||||||
0x0170: u"Unisys NAP ADPCM",
|
|
||||||
0x0171: u"Unisys NAP ULAW",
|
|
||||||
0x0172: u"Unisys NAP ALAW",
|
|
||||||
0x0173: u"Unisys NAP 16K",
|
|
||||||
0x0174: u"Sycom ACM SYC008",
|
|
||||||
0x0175: u"Sycom ACM SYC701 G725",
|
|
||||||
0x0176: u"Sycom ACM SYC701 CELP54",
|
|
||||||
0x0177: u"Sycom ACM SYC701 CELP68",
|
|
||||||
0x0178: u"Knowledge Adventure ADPCM",
|
|
||||||
0x0180: u"Fraunhofer IIS MPEG-2 AAC",
|
|
||||||
0x0190: u"Digital Theater Systems DTS",
|
|
||||||
0x0200: u"Creative Labs ADPCM",
|
|
||||||
0x0202: u"Creative Labs FastSpeech8",
|
|
||||||
0x0203: u"Creative Labs FastSpeech10",
|
|
||||||
0x0210: u"UHER informatic GmbH ADPCM",
|
|
||||||
0x0215: u"Ulead DV Audio",
|
|
||||||
0x0216: u"Ulead DV Audio",
|
|
||||||
0x0220: u"Quarterdeck",
|
|
||||||
0x0230: u"I-link Worldwide ILINK VC",
|
|
||||||
0x0240: u"Aureal Semiconductor RAW SPORT",
|
|
||||||
0x0249: u"Generic Passthru",
|
|
||||||
0x0250: u"Interactive Products HSX",
|
|
||||||
0x0251: u"Interactive Products RPELP",
|
|
||||||
0x0260: u"Consistent Software CS2",
|
|
||||||
0x0270: u"Sony SCX",
|
|
||||||
0x0271: u"Sony SCY",
|
|
||||||
0x0272: u"Sony ATRAC3",
|
|
||||||
0x0273: u"Sony SPC",
|
|
||||||
0x0280: u"Telum Audio",
|
|
||||||
0x0281: u"Telum IA Audio",
|
|
||||||
0x0285: u"Norcom Voice Systems ADPCM",
|
|
||||||
0x0300: u"Fujitsu TOWNS SND",
|
|
||||||
0x0350: u"Micronas SC4 Speech",
|
|
||||||
0x0351: u"Micronas CELP833",
|
|
||||||
0x0400: u"Brooktree BTV Digital",
|
|
||||||
0x0401: u"Intel Music Coder",
|
|
||||||
0x0402: u"Intel Audio",
|
|
||||||
0x0450: u"QDesign Music",
|
|
||||||
0x0500: u"On2 AVC0 Audio",
|
|
||||||
0x0501: u"On2 AVC1 Audio",
|
|
||||||
0x0680: u"AT&T Labs VME VMPCM",
|
|
||||||
0x0681: u"AT&T Labs TPC",
|
|
||||||
0x08AE: u"ClearJump Lightwave Lossless",
|
|
||||||
0x1000: u"Olivetti GSM",
|
|
||||||
0x1001: u"Olivetti ADPCM",
|
|
||||||
0x1002: u"Olivetti CELP",
|
|
||||||
0x1003: u"Olivetti SBC",
|
|
||||||
0x1004: u"Olivetti OPR",
|
|
||||||
0x1100: u"Lernout & Hauspie",
|
|
||||||
0x1101: u"Lernout & Hauspie CELP",
|
|
||||||
0x1102: u"Lernout & Hauspie SBC8",
|
|
||||||
0x1103: u"Lernout & Hauspie SBC12",
|
|
||||||
0x1104: u"Lernout & Hauspie SBC16",
|
|
||||||
0x1400: u"Norris Communication",
|
|
||||||
0x1401: u"ISIAudio",
|
|
||||||
0x1500: u"AT&T Labs Soundspace Music Compression",
|
|
||||||
0x1600: u"Microsoft MPEG ADTS AAC",
|
|
||||||
0x1601: u"Microsoft MPEG RAW AAC",
|
|
||||||
0x1608: u"Nokia MPEG ADTS AAC",
|
|
||||||
0x1609: u"Nokia MPEG RAW AAC",
|
|
||||||
0x181C: u"VoxWare MetaVoice RT24",
|
|
||||||
0x1971: u"Sonic Foundry Lossless",
|
|
||||||
0x1979: u"Innings Telecom ADPCM",
|
|
||||||
0x1FC4: u"NTCSoft ALF2CD ACM",
|
|
||||||
0x2000: u"Dolby AC3",
|
|
||||||
0x2001: u"DTS",
|
|
||||||
0x4143: u"Divio AAC",
|
|
||||||
0x4201: u"Nokia Adaptive Multi-Rate",
|
|
||||||
0x4243: u"Divio G.726",
|
|
||||||
0x4261: u"ITU-T H.261",
|
|
||||||
0x4263: u"ITU-T H.263",
|
|
||||||
0x4264: u"ITU-T H.264",
|
|
||||||
0x674F: u"Ogg Vorbis Mode 1",
|
|
||||||
0x6750: u"Ogg Vorbis Mode 2",
|
|
||||||
0x6751: u"Ogg Vorbis Mode 3",
|
|
||||||
0x676F: u"Ogg Vorbis Mode 1+",
|
|
||||||
0x6770: u"Ogg Vorbis Mode 2+",
|
|
||||||
0x6771: u"Ogg Vorbis Mode 3+",
|
|
||||||
0x7000: u"3COM NBX Audio",
|
|
||||||
0x706D: u"FAAD AAC Audio",
|
|
||||||
0x77A1: u"True Audio Lossless Audio",
|
|
||||||
0x7A21: u"GSM-AMR CBR 3GPP Audio",
|
|
||||||
0x7A22: u"GSM-AMR VBR 3GPP Audio",
|
|
||||||
0xA100: u"Comverse Infosys G723.1",
|
|
||||||
0xA101: u"Comverse Infosys AVQSBC",
|
|
||||||
0xA102: u"Comverse Infosys SBC",
|
|
||||||
0xA103: u"Symbol Technologies G729a",
|
|
||||||
0xA104: u"VoiceAge AMR WB",
|
|
||||||
0xA105: u"Ingenient Technologies G.726",
|
|
||||||
0xA106: u"ISO/MPEG-4 Advanced Audio Coding (AAC)",
|
|
||||||
0xA107: u"Encore Software Ltd's G.726",
|
|
||||||
0xA108: u"ZOLL Medical Corporation ASAO",
|
|
||||||
0xA109: u"Speex Voice",
|
|
||||||
0xA10A: u"Vianix MASC Speech Compression",
|
|
||||||
0xA10B: u"Windows Media 9 Spectrum Analyzer Output",
|
|
||||||
0xA10C: u"Media Foundation Spectrum Analyzer Output",
|
|
||||||
0xA10D: u"GSM 6.10 (Full-Rate) Speech",
|
|
||||||
0xA10E: u"GSM 6.20 (Half-Rate) Speech",
|
|
||||||
0xA10F: u"GSM 6.60 (Enchanced Full-Rate) Speech",
|
|
||||||
0xA110: u"GSM 6.90 (Adaptive Multi-Rate) Speech",
|
|
||||||
0xA111: u"GSM Adaptive Multi-Rate WideBand Speech",
|
|
||||||
0xA112: u"Polycom G.722",
|
|
||||||
0xA113: u"Polycom G.728",
|
|
||||||
0xA114: u"Polycom G.729a",
|
|
||||||
0xA115: u"Polycom Siren",
|
|
||||||
0xA116: u"Global IP Sound ILBC",
|
|
||||||
0xA117: u"Radio Time Time Shifted Radio",
|
|
||||||
0xA118: u"Nice Systems ACA",
|
|
||||||
0xA119: u"Nice Systems ADPCM",
|
|
||||||
0xA11A: u"Vocord Group ITU-T G.721",
|
|
||||||
0xA11B: u"Vocord Group ITU-T G.726",
|
|
||||||
0xA11C: u"Vocord Group ITU-T G.722.1",
|
|
||||||
0xA11D: u"Vocord Group ITU-T G.728",
|
|
||||||
0xA11E: u"Vocord Group ITU-T G.729",
|
|
||||||
0xA11F: u"Vocord Group ITU-T G.729a",
|
|
||||||
0xA120: u"Vocord Group ITU-T G.723.1",
|
|
||||||
0xA121: u"Vocord Group LBC",
|
|
||||||
0xA122: u"Nice G.728",
|
|
||||||
0xA123: u"France Telecom G.729 ACM Audio",
|
|
||||||
0xA124: u"CODIAN Audio",
|
|
||||||
0xCC12: u"Intel YUV12 Codec",
|
|
||||||
0xCFCC: u"Digital Processing Systems Perception Motion JPEG",
|
|
||||||
0xD261: u"DEC H.261",
|
|
||||||
0xD263: u"DEC H.263",
|
|
||||||
0xFFFE: u"Extensible Wave Format",
|
|
||||||
0xFFFF: u"Unregistered",
|
|
||||||
}
|
|
|
@ -1,534 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2006 Joe Wreschnig
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of version 2 of the GNU General Public License as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""Easier access to ID3 tags.
|
|
||||||
|
|
||||||
EasyID3 is a wrapper around mutagen.id3.ID3 to make ID3 tags appear
|
|
||||||
more like Vorbis or APEv2 tags.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import mutagen.id3
|
|
||||||
|
|
||||||
from ._compat import iteritems, text_type, PY2
|
|
||||||
from mutagen import Metadata
|
|
||||||
from mutagen._util import DictMixin, dict_match
|
|
||||||
from mutagen.id3 import ID3, error, delete, ID3FileType
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['EasyID3', 'Open', 'delete']
|
|
||||||
|
|
||||||
|
|
||||||
class EasyID3KeyError(KeyError, ValueError, error):
|
|
||||||
"""Raised when trying to get/set an invalid key.
|
|
||||||
|
|
||||||
Subclasses both KeyError and ValueError for API compatibility,
|
|
||||||
catching KeyError is preferred.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class EasyID3(DictMixin, Metadata):
|
|
||||||
"""A file with an ID3 tag.
|
|
||||||
|
|
||||||
Like Vorbis comments, EasyID3 keys are case-insensitive ASCII
|
|
||||||
strings. Only a subset of ID3 frames are supported by default. Use
|
|
||||||
EasyID3.RegisterKey and its wrappers to support more.
|
|
||||||
|
|
||||||
You can also set the GetFallback, SetFallback, and DeleteFallback
|
|
||||||
to generic key getter/setter/deleter functions, which are called
|
|
||||||
if no specific handler is registered for a key. Additionally,
|
|
||||||
ListFallback can be used to supply an arbitrary list of extra
|
|
||||||
keys. These can be set on EasyID3 or on individual instances after
|
|
||||||
creation.
|
|
||||||
|
|
||||||
To use an EasyID3 class with mutagen.mp3.MP3::
|
|
||||||
|
|
||||||
from mutagen.mp3 import EasyMP3 as MP3
|
|
||||||
MP3(filename)
|
|
||||||
|
|
||||||
Because many of the attributes are constructed on the fly, things
|
|
||||||
like the following will not work::
|
|
||||||
|
|
||||||
ezid3["performer"].append("Joe")
|
|
||||||
|
|
||||||
Instead, you must do::
|
|
||||||
|
|
||||||
values = ezid3["performer"]
|
|
||||||
values.append("Joe")
|
|
||||||
ezid3["performer"] = values
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
Set = {}
|
|
||||||
Get = {}
|
|
||||||
Delete = {}
|
|
||||||
List = {}
|
|
||||||
|
|
||||||
# For compatibility.
|
|
||||||
valid_keys = Get
|
|
||||||
|
|
||||||
GetFallback = None
|
|
||||||
SetFallback = None
|
|
||||||
DeleteFallback = None
|
|
||||||
ListFallback = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def RegisterKey(cls, key,
|
|
||||||
getter=None, setter=None, deleter=None, lister=None):
|
|
||||||
"""Register a new key mapping.
|
|
||||||
|
|
||||||
A key mapping is four functions, a getter, setter, deleter,
|
|
||||||
and lister. The key may be either a string or a glob pattern.
|
|
||||||
|
|
||||||
The getter, deleted, and lister receive an ID3 instance and
|
|
||||||
the requested key name. The setter also receives the desired
|
|
||||||
value, which will be a list of strings.
|
|
||||||
|
|
||||||
The getter, setter, and deleter are used to implement __getitem__,
|
|
||||||
__setitem__, and __delitem__.
|
|
||||||
|
|
||||||
The lister is used to implement keys(). It should return a
|
|
||||||
list of keys that are actually in the ID3 instance, provided
|
|
||||||
by its associated getter.
|
|
||||||
"""
|
|
||||||
key = key.lower()
|
|
||||||
if getter is not None:
|
|
||||||
cls.Get[key] = getter
|
|
||||||
if setter is not None:
|
|
||||||
cls.Set[key] = setter
|
|
||||||
if deleter is not None:
|
|
||||||
cls.Delete[key] = deleter
|
|
||||||
if lister is not None:
|
|
||||||
cls.List[key] = lister
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def RegisterTextKey(cls, key, frameid):
|
|
||||||
"""Register a text key.
|
|
||||||
|
|
||||||
If the key you need to register is a simple one-to-one mapping
|
|
||||||
of ID3 frame name to EasyID3 key, then you can use this
|
|
||||||
function::
|
|
||||||
|
|
||||||
EasyID3.RegisterTextKey("title", "TIT2")
|
|
||||||
"""
|
|
||||||
def getter(id3, key):
|
|
||||||
return list(id3[frameid])
|
|
||||||
|
|
||||||
def setter(id3, key, value):
|
|
||||||
try:
|
|
||||||
frame = id3[frameid]
|
|
||||||
except KeyError:
|
|
||||||
id3.add(mutagen.id3.Frames[frameid](encoding=3, text=value))
|
|
||||||
else:
|
|
||||||
frame.encoding = 3
|
|
||||||
frame.text = value
|
|
||||||
|
|
||||||
def deleter(id3, key):
|
|
||||||
del(id3[frameid])
|
|
||||||
|
|
||||||
cls.RegisterKey(key, getter, setter, deleter)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def RegisterTXXXKey(cls, key, desc):
|
|
||||||
"""Register a user-defined text frame key.
|
|
||||||
|
|
||||||
Some ID3 tags are stored in TXXX frames, which allow a
|
|
||||||
freeform 'description' which acts as a subkey,
|
|
||||||
e.g. TXXX:BARCODE.::
|
|
||||||
|
|
||||||
EasyID3.RegisterTXXXKey('barcode', 'BARCODE').
|
|
||||||
"""
|
|
||||||
frameid = "TXXX:" + desc
|
|
||||||
|
|
||||||
def getter(id3, key):
|
|
||||||
return list(id3[frameid])
|
|
||||||
|
|
||||||
def setter(id3, key, value):
|
|
||||||
try:
|
|
||||||
frame = id3[frameid]
|
|
||||||
except KeyError:
|
|
||||||
enc = 0
|
|
||||||
# Store 8859-1 if we can, per MusicBrainz spec.
|
|
||||||
for v in value:
|
|
||||||
if v and max(v) > u'\x7f':
|
|
||||||
enc = 3
|
|
||||||
break
|
|
||||||
|
|
||||||
id3.add(mutagen.id3.TXXX(encoding=enc, text=value, desc=desc))
|
|
||||||
else:
|
|
||||||
frame.text = value
|
|
||||||
|
|
||||||
def deleter(id3, key):
|
|
||||||
del(id3[frameid])
|
|
||||||
|
|
||||||
cls.RegisterKey(key, getter, setter, deleter)
|
|
||||||
|
|
||||||
def __init__(self, filename=None):
|
|
||||||
self.__id3 = ID3()
|
|
||||||
if filename is not None:
|
|
||||||
self.load(filename)
|
|
||||||
|
|
||||||
load = property(lambda s: s.__id3.load,
|
|
||||||
lambda s, v: setattr(s.__id3, 'load', v))
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
# ignore v2_version until we support 2.3 here
|
|
||||||
kwargs.pop("v2_version", None)
|
|
||||||
self.__id3.save(*args, **kwargs)
|
|
||||||
|
|
||||||
delete = property(lambda s: s.__id3.delete,
|
|
||||||
lambda s, v: setattr(s.__id3, 'delete', v))
|
|
||||||
|
|
||||||
filename = property(lambda s: s.__id3.filename,
|
|
||||||
lambda s, fn: setattr(s.__id3, 'filename', fn))
|
|
||||||
|
|
||||||
size = property(lambda s: s.__id3.size,
|
|
||||||
lambda s, fn: setattr(s.__id3, 'size', s))
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
key = key.lower()
|
|
||||||
func = dict_match(self.Get, key, self.GetFallback)
|
|
||||||
if func is not None:
|
|
||||||
return func(self.__id3, key)
|
|
||||||
else:
|
|
||||||
raise EasyID3KeyError("%r is not a valid key" % key)
|
|
||||||
|
|
||||||
def __setitem__(self, key, value):
|
|
||||||
key = key.lower()
|
|
||||||
if PY2:
|
|
||||||
if isinstance(value, basestring):
|
|
||||||
value = [value]
|
|
||||||
else:
|
|
||||||
if isinstance(value, text_type):
|
|
||||||
value = [value]
|
|
||||||
func = dict_match(self.Set, key, self.SetFallback)
|
|
||||||
if func is not None:
|
|
||||||
return func(self.__id3, key, value)
|
|
||||||
else:
|
|
||||||
raise EasyID3KeyError("%r is not a valid key" % key)
|
|
||||||
|
|
||||||
def __delitem__(self, key):
|
|
||||||
key = key.lower()
|
|
||||||
func = dict_match(self.Delete, key, self.DeleteFallback)
|
|
||||||
if func is not None:
|
|
||||||
return func(self.__id3, key)
|
|
||||||
else:
|
|
||||||
raise EasyID3KeyError("%r is not a valid key" % key)
|
|
||||||
|
|
||||||
def keys(self):
|
|
||||||
keys = []
|
|
||||||
for key in self.Get.keys():
|
|
||||||
if key in self.List:
|
|
||||||
keys.extend(self.List[key](self.__id3, key))
|
|
||||||
elif key in self:
|
|
||||||
keys.append(key)
|
|
||||||
if self.ListFallback is not None:
|
|
||||||
keys.extend(self.ListFallback(self.__id3, ""))
|
|
||||||
return keys
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
"""Print tag key=value pairs."""
|
|
||||||
strings = []
|
|
||||||
for key in sorted(self.keys()):
|
|
||||||
values = self[key]
|
|
||||||
for value in values:
|
|
||||||
strings.append("%s=%s" % (key, value))
|
|
||||||
return "\n".join(strings)
|
|
||||||
|
|
||||||
|
|
||||||
Open = EasyID3
|
|
||||||
|
|
||||||
|
|
||||||
def genre_get(id3, key):
|
|
||||||
return id3["TCON"].genres
|
|
||||||
|
|
||||||
|
|
||||||
def genre_set(id3, key, value):
|
|
||||||
try:
|
|
||||||
frame = id3["TCON"]
|
|
||||||
except KeyError:
|
|
||||||
id3.add(mutagen.id3.TCON(encoding=3, text=value))
|
|
||||||
else:
|
|
||||||
frame.encoding = 3
|
|
||||||
frame.genres = value
|
|
||||||
|
|
||||||
|
|
||||||
def genre_delete(id3, key):
|
|
||||||
del(id3["TCON"])
|
|
||||||
|
|
||||||
|
|
||||||
def date_get(id3, key):
|
|
||||||
return [stamp.text for stamp in id3["TDRC"].text]
|
|
||||||
|
|
||||||
|
|
||||||
def date_set(id3, key, value):
|
|
||||||
id3.add(mutagen.id3.TDRC(encoding=3, text=value))
|
|
||||||
|
|
||||||
|
|
||||||
def date_delete(id3, key):
|
|
||||||
del(id3["TDRC"])
|
|
||||||
|
|
||||||
|
|
||||||
def original_date_get(id3, key):
|
|
||||||
return [stamp.text for stamp in id3["TDOR"].text]
|
|
||||||
|
|
||||||
|
|
||||||
def original_date_set(id3, key, value):
|
|
||||||
id3.add(mutagen.id3.TDOR(encoding=3, text=value))
|
|
||||||
|
|
||||||
|
|
||||||
def original_date_delete(id3, key):
|
|
||||||
del(id3["TDOR"])
|
|
||||||
|
|
||||||
|
|
||||||
def performer_get(id3, key):
|
|
||||||
people = []
|
|
||||||
wanted_role = key.split(":", 1)[1]
|
|
||||||
try:
|
|
||||||
mcl = id3["TMCL"]
|
|
||||||
except KeyError:
|
|
||||||
raise KeyError(key)
|
|
||||||
for role, person in mcl.people:
|
|
||||||
if role == wanted_role:
|
|
||||||
people.append(person)
|
|
||||||
if people:
|
|
||||||
return people
|
|
||||||
else:
|
|
||||||
raise KeyError(key)
|
|
||||||
|
|
||||||
|
|
||||||
def performer_set(id3, key, value):
|
|
||||||
wanted_role = key.split(":", 1)[1]
|
|
||||||
try:
|
|
||||||
mcl = id3["TMCL"]
|
|
||||||
except KeyError:
|
|
||||||
mcl = mutagen.id3.TMCL(encoding=3, people=[])
|
|
||||||
id3.add(mcl)
|
|
||||||
mcl.encoding = 3
|
|
||||||
people = [p for p in mcl.people if p[0] != wanted_role]
|
|
||||||
for v in value:
|
|
||||||
people.append((wanted_role, v))
|
|
||||||
mcl.people = people
|
|
||||||
|
|
||||||
|
|
||||||
def performer_delete(id3, key):
|
|
||||||
wanted_role = key.split(":", 1)[1]
|
|
||||||
try:
|
|
||||||
mcl = id3["TMCL"]
|
|
||||||
except KeyError:
|
|
||||||
raise KeyError(key)
|
|
||||||
people = [p for p in mcl.people if p[0] != wanted_role]
|
|
||||||
if people == mcl.people:
|
|
||||||
raise KeyError(key)
|
|
||||||
elif people:
|
|
||||||
mcl.people = people
|
|
||||||
else:
|
|
||||||
del(id3["TMCL"])
|
|
||||||
|
|
||||||
|
|
||||||
def performer_list(id3, key):
|
|
||||||
try:
|
|
||||||
mcl = id3["TMCL"]
|
|
||||||
except KeyError:
|
|
||||||
return []
|
|
||||||
else:
|
|
||||||
return list(set("performer:" + p[0] for p in mcl.people))
|
|
||||||
|
|
||||||
|
|
||||||
def musicbrainz_trackid_get(id3, key):
|
|
||||||
return [id3["UFID:http://musicbrainz.org"].data.decode('ascii')]
|
|
||||||
|
|
||||||
|
|
||||||
def musicbrainz_trackid_set(id3, key, value):
|
|
||||||
if len(value) != 1:
|
|
||||||
raise ValueError("only one track ID may be set per song")
|
|
||||||
value = value[0].encode('ascii')
|
|
||||||
try:
|
|
||||||
frame = id3["UFID:http://musicbrainz.org"]
|
|
||||||
except KeyError:
|
|
||||||
frame = mutagen.id3.UFID(owner="http://musicbrainz.org", data=value)
|
|
||||||
id3.add(frame)
|
|
||||||
else:
|
|
||||||
frame.data = value
|
|
||||||
|
|
||||||
|
|
||||||
def musicbrainz_trackid_delete(id3, key):
|
|
||||||
del(id3["UFID:http://musicbrainz.org"])
|
|
||||||
|
|
||||||
|
|
||||||
def website_get(id3, key):
|
|
||||||
urls = [frame.url for frame in id3.getall("WOAR")]
|
|
||||||
if urls:
|
|
||||||
return urls
|
|
||||||
else:
|
|
||||||
raise EasyID3KeyError(key)
|
|
||||||
|
|
||||||
|
|
||||||
def website_set(id3, key, value):
|
|
||||||
id3.delall("WOAR")
|
|
||||||
for v in value:
|
|
||||||
id3.add(mutagen.id3.WOAR(url=v))
|
|
||||||
|
|
||||||
|
|
||||||
def website_delete(id3, key):
|
|
||||||
id3.delall("WOAR")
|
|
||||||
|
|
||||||
|
|
||||||
def gain_get(id3, key):
|
|
||||||
try:
|
|
||||||
frame = id3["RVA2:" + key[11:-5]]
|
|
||||||
except KeyError:
|
|
||||||
raise EasyID3KeyError(key)
|
|
||||||
else:
|
|
||||||
return [u"%+f dB" % frame.gain]
|
|
||||||
|
|
||||||
|
|
||||||
def gain_set(id3, key, value):
|
|
||||||
if len(value) != 1:
|
|
||||||
raise ValueError(
|
|
||||||
"there must be exactly one gain value, not %r.", value)
|
|
||||||
gain = float(value[0].split()[0])
|
|
||||||
try:
|
|
||||||
frame = id3["RVA2:" + key[11:-5]]
|
|
||||||
except KeyError:
|
|
||||||
frame = mutagen.id3.RVA2(desc=key[11:-5], gain=0, peak=0, channel=1)
|
|
||||||
id3.add(frame)
|
|
||||||
frame.gain = gain
|
|
||||||
|
|
||||||
|
|
||||||
def gain_delete(id3, key):
|
|
||||||
try:
|
|
||||||
frame = id3["RVA2:" + key[11:-5]]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if frame.peak:
|
|
||||||
frame.gain = 0.0
|
|
||||||
else:
|
|
||||||
del(id3["RVA2:" + key[11:-5]])
|
|
||||||
|
|
||||||
|
|
||||||
def peak_get(id3, key):
|
|
||||||
try:
|
|
||||||
frame = id3["RVA2:" + key[11:-5]]
|
|
||||||
except KeyError:
|
|
||||||
raise EasyID3KeyError(key)
|
|
||||||
else:
|
|
||||||
return [u"%f" % frame.peak]
|
|
||||||
|
|
||||||
|
|
||||||
def peak_set(id3, key, value):
|
|
||||||
if len(value) != 1:
|
|
||||||
raise ValueError(
|
|
||||||
"there must be exactly one peak value, not %r.", value)
|
|
||||||
peak = float(value[0])
|
|
||||||
if peak >= 2 or peak < 0:
|
|
||||||
raise ValueError("peak must be => 0 and < 2.")
|
|
||||||
try:
|
|
||||||
frame = id3["RVA2:" + key[11:-5]]
|
|
||||||
except KeyError:
|
|
||||||
frame = mutagen.id3.RVA2(desc=key[11:-5], gain=0, peak=0, channel=1)
|
|
||||||
id3.add(frame)
|
|
||||||
frame.peak = peak
|
|
||||||
|
|
||||||
|
|
||||||
def peak_delete(id3, key):
|
|
||||||
try:
|
|
||||||
frame = id3["RVA2:" + key[11:-5]]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if frame.gain:
|
|
||||||
frame.peak = 0.0
|
|
||||||
else:
|
|
||||||
del(id3["RVA2:" + key[11:-5]])
|
|
||||||
|
|
||||||
|
|
||||||
def peakgain_list(id3, key):
|
|
||||||
keys = []
|
|
||||||
for frame in id3.getall("RVA2"):
|
|
||||||
keys.append("replaygain_%s_gain" % frame.desc)
|
|
||||||
keys.append("replaygain_%s_peak" % frame.desc)
|
|
||||||
return keys
|
|
||||||
|
|
||||||
for frameid, key in iteritems({
|
|
||||||
"TALB": "album",
|
|
||||||
"TBPM": "bpm",
|
|
||||||
"TCMP": "compilation", # iTunes extension
|
|
||||||
"TCOM": "composer",
|
|
||||||
"TCOP": "copyright",
|
|
||||||
"TENC": "encodedby",
|
|
||||||
"TEXT": "lyricist",
|
|
||||||
"TLEN": "length",
|
|
||||||
"TMED": "media",
|
|
||||||
"TMOO": "mood",
|
|
||||||
"TIT2": "title",
|
|
||||||
"TIT3": "version",
|
|
||||||
"TPE1": "artist",
|
|
||||||
"TPE2": "performer",
|
|
||||||
"TPE3": "conductor",
|
|
||||||
"TPE4": "arranger",
|
|
||||||
"TPOS": "discnumber",
|
|
||||||
"TPUB": "organization",
|
|
||||||
"TRCK": "tracknumber",
|
|
||||||
"TOLY": "author",
|
|
||||||
"TSO2": "albumartistsort", # iTunes extension
|
|
||||||
"TSOA": "albumsort",
|
|
||||||
"TSOC": "composersort", # iTunes extension
|
|
||||||
"TSOP": "artistsort",
|
|
||||||
"TSOT": "titlesort",
|
|
||||||
"TSRC": "isrc",
|
|
||||||
"TSST": "discsubtitle",
|
|
||||||
"TLAN": "language",
|
|
||||||
}):
|
|
||||||
EasyID3.RegisterTextKey(key, frameid)
|
|
||||||
|
|
||||||
EasyID3.RegisterKey("genre", genre_get, genre_set, genre_delete)
|
|
||||||
EasyID3.RegisterKey("date", date_get, date_set, date_delete)
|
|
||||||
EasyID3.RegisterKey("originaldate", original_date_get, original_date_set,
|
|
||||||
original_date_delete)
|
|
||||||
EasyID3.RegisterKey(
|
|
||||||
"performer:*", performer_get, performer_set, performer_delete,
|
|
||||||
performer_list)
|
|
||||||
EasyID3.RegisterKey("musicbrainz_trackid", musicbrainz_trackid_get,
|
|
||||||
musicbrainz_trackid_set, musicbrainz_trackid_delete)
|
|
||||||
EasyID3.RegisterKey("website", website_get, website_set, website_delete)
|
|
||||||
EasyID3.RegisterKey(
|
|
||||||
"replaygain_*_gain", gain_get, gain_set, gain_delete, peakgain_list)
|
|
||||||
EasyID3.RegisterKey("replaygain_*_peak", peak_get, peak_set, peak_delete)
|
|
||||||
|
|
||||||
# At various times, information for this came from
|
|
||||||
# http://musicbrainz.org/docs/specs/metadata_tags.html
|
|
||||||
# http://bugs.musicbrainz.org/ticket/1383
|
|
||||||
# http://musicbrainz.org/doc/MusicBrainzTag
|
|
||||||
for desc, key in iteritems({
|
|
||||||
u"MusicBrainz Artist Id": "musicbrainz_artistid",
|
|
||||||
u"MusicBrainz Album Id": "musicbrainz_albumid",
|
|
||||||
u"MusicBrainz Album Artist Id": "musicbrainz_albumartistid",
|
|
||||||
u"MusicBrainz TRM Id": "musicbrainz_trmid",
|
|
||||||
u"MusicIP PUID": "musicip_puid",
|
|
||||||
u"MusicMagic Fingerprint": "musicip_fingerprint",
|
|
||||||
u"MusicBrainz Album Status": "musicbrainz_albumstatus",
|
|
||||||
u"MusicBrainz Album Type": "musicbrainz_albumtype",
|
|
||||||
u"MusicBrainz Album Release Country": "releasecountry",
|
|
||||||
u"MusicBrainz Disc Id": "musicbrainz_discid",
|
|
||||||
u"ASIN": "asin",
|
|
||||||
u"ALBUMARTISTSORT": "albumartistsort",
|
|
||||||
u"BARCODE": "barcode",
|
|
||||||
u"CATALOGNUMBER": "catalognumber",
|
|
||||||
u"MusicBrainz Release Track Id": "musicbrainz_releasetrackid",
|
|
||||||
u"MusicBrainz Release Group Id": "musicbrainz_releasegroupid",
|
|
||||||
u"MusicBrainz Work Id": "musicbrainz_workid",
|
|
||||||
u"Acoustid Fingerprint": "acoustid_fingerprint",
|
|
||||||
u"Acoustid Id": "acoustid_id",
|
|
||||||
}):
|
|
||||||
EasyID3.RegisterTXXXKey(key, desc)
|
|
||||||
|
|
||||||
|
|
||||||
class EasyID3FileType(ID3FileType):
|
|
||||||
"""Like ID3FileType, but uses EasyID3 for tags."""
|
|
||||||
ID3 = EasyID3
|
|
|
@ -1,285 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2009 Joe Wreschnig
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of version 2 of the GNU General Public License as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
from mutagen import Metadata
|
|
||||||
from mutagen._util import DictMixin, dict_match
|
|
||||||
from mutagen.mp4 import MP4, MP4Tags, error, delete
|
|
||||||
from ._compat import PY2, text_type, PY3
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["EasyMP4Tags", "EasyMP4", "delete", "error"]
|
|
||||||
|
|
||||||
|
|
||||||
class EasyMP4KeyError(error, KeyError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class EasyMP4Tags(DictMixin, Metadata):
|
|
||||||
"""A file with MPEG-4 iTunes metadata.
|
|
||||||
|
|
||||||
Like Vorbis comments, EasyMP4Tags keys are case-insensitive ASCII
|
|
||||||
strings, and values are a list of Unicode strings (and these lists
|
|
||||||
are always of length 0 or 1).
|
|
||||||
|
|
||||||
If you need access to the full MP4 metadata feature set, you should use
|
|
||||||
MP4, not EasyMP4.
|
|
||||||
"""
|
|
||||||
|
|
||||||
Set = {}
|
|
||||||
Get = {}
|
|
||||||
Delete = {}
|
|
||||||
List = {}
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
self.__mp4 = MP4Tags(*args, **kwargs)
|
|
||||||
self.load = self.__mp4.load
|
|
||||||
self.save = self.__mp4.save
|
|
||||||
self.delete = self.__mp4.delete
|
|
||||||
self._padding = self.__mp4._padding
|
|
||||||
|
|
||||||
filename = property(lambda s: s.__mp4.filename,
|
|
||||||
lambda s, fn: setattr(s.__mp4, 'filename', fn))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def RegisterKey(cls, key,
|
|
||||||
getter=None, setter=None, deleter=None, lister=None):
|
|
||||||
"""Register a new key mapping.
|
|
||||||
|
|
||||||
A key mapping is four functions, a getter, setter, deleter,
|
|
||||||
and lister. The key may be either a string or a glob pattern.
|
|
||||||
|
|
||||||
The getter, deleted, and lister receive an MP4Tags instance
|
|
||||||
and the requested key name. The setter also receives the
|
|
||||||
desired value, which will be a list of strings.
|
|
||||||
|
|
||||||
The getter, setter, and deleter are used to implement __getitem__,
|
|
||||||
__setitem__, and __delitem__.
|
|
||||||
|
|
||||||
The lister is used to implement keys(). It should return a
|
|
||||||
list of keys that are actually in the MP4 instance, provided
|
|
||||||
by its associated getter.
|
|
||||||
"""
|
|
||||||
key = key.lower()
|
|
||||||
if getter is not None:
|
|
||||||
cls.Get[key] = getter
|
|
||||||
if setter is not None:
|
|
||||||
cls.Set[key] = setter
|
|
||||||
if deleter is not None:
|
|
||||||
cls.Delete[key] = deleter
|
|
||||||
if lister is not None:
|
|
||||||
cls.List[key] = lister
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def RegisterTextKey(cls, key, atomid):
|
|
||||||
"""Register a text key.
|
|
||||||
|
|
||||||
If the key you need to register is a simple one-to-one mapping
|
|
||||||
of MP4 atom name to EasyMP4Tags key, then you can use this
|
|
||||||
function::
|
|
||||||
|
|
||||||
EasyMP4Tags.RegisterTextKey("artist", "\xa9ART")
|
|
||||||
"""
|
|
||||||
def getter(tags, key):
|
|
||||||
return tags[atomid]
|
|
||||||
|
|
||||||
def setter(tags, key, value):
|
|
||||||
tags[atomid] = value
|
|
||||||
|
|
||||||
def deleter(tags, key):
|
|
||||||
del(tags[atomid])
|
|
||||||
|
|
||||||
cls.RegisterKey(key, getter, setter, deleter)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def RegisterIntKey(cls, key, atomid, min_value=0, max_value=(2 ** 16) - 1):
|
|
||||||
"""Register a scalar integer key.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def getter(tags, key):
|
|
||||||
return list(map(text_type, tags[atomid]))
|
|
||||||
|
|
||||||
def setter(tags, key, value):
|
|
||||||
clamp = lambda x: int(min(max(min_value, x), max_value))
|
|
||||||
tags[atomid] = [clamp(v) for v in map(int, value)]
|
|
||||||
|
|
||||||
def deleter(tags, key):
|
|
||||||
del(tags[atomid])
|
|
||||||
|
|
||||||
cls.RegisterKey(key, getter, setter, deleter)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def RegisterIntPairKey(cls, key, atomid, min_value=0,
|
|
||||||
max_value=(2 ** 16) - 1):
|
|
||||||
def getter(tags, key):
|
|
||||||
ret = []
|
|
||||||
for (track, total) in tags[atomid]:
|
|
||||||
if total:
|
|
||||||
ret.append(u"%d/%d" % (track, total))
|
|
||||||
else:
|
|
||||||
ret.append(text_type(track))
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def setter(tags, key, value):
|
|
||||||
clamp = lambda x: int(min(max(min_value, x), max_value))
|
|
||||||
data = []
|
|
||||||
for v in value:
|
|
||||||
try:
|
|
||||||
tracks, total = v.split("/")
|
|
||||||
tracks = clamp(int(tracks))
|
|
||||||
total = clamp(int(total))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
tracks = clamp(int(v))
|
|
||||||
total = min_value
|
|
||||||
data.append((tracks, total))
|
|
||||||
tags[atomid] = data
|
|
||||||
|
|
||||||
def deleter(tags, key):
|
|
||||||
del(tags[atomid])
|
|
||||||
|
|
||||||
cls.RegisterKey(key, getter, setter, deleter)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def RegisterFreeformKey(cls, key, name, mean="com.apple.iTunes"):
|
|
||||||
"""Register a text key.
|
|
||||||
|
|
||||||
If the key you need to register is a simple one-to-one mapping
|
|
||||||
of MP4 freeform atom (----) and name to EasyMP4Tags key, then
|
|
||||||
you can use this function::
|
|
||||||
|
|
||||||
EasyMP4Tags.RegisterFreeformKey(
|
|
||||||
"musicbrainz_artistid", "MusicBrainz Artist Id")
|
|
||||||
"""
|
|
||||||
atomid = "----:" + mean + ":" + name
|
|
||||||
|
|
||||||
def getter(tags, key):
|
|
||||||
return [s.decode("utf-8", "replace") for s in tags[atomid]]
|
|
||||||
|
|
||||||
def setter(tags, key, value):
|
|
||||||
encoded = []
|
|
||||||
for v in value:
|
|
||||||
if not isinstance(v, text_type):
|
|
||||||
if PY3:
|
|
||||||
raise TypeError("%r not str" % v)
|
|
||||||
v = v.decode("utf-8")
|
|
||||||
encoded.append(v.encode("utf-8"))
|
|
||||||
tags[atomid] = encoded
|
|
||||||
|
|
||||||
def deleter(tags, key):
|
|
||||||
del(tags[atomid])
|
|
||||||
|
|
||||||
cls.RegisterKey(key, getter, setter, deleter)
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
key = key.lower()
|
|
||||||
func = dict_match(self.Get, key)
|
|
||||||
if func is not None:
|
|
||||||
return func(self.__mp4, key)
|
|
||||||
else:
|
|
||||||
raise EasyMP4KeyError("%r is not a valid key" % key)
|
|
||||||
|
|
||||||
def __setitem__(self, key, value):
|
|
||||||
key = key.lower()
|
|
||||||
|
|
||||||
if PY2:
|
|
||||||
if isinstance(value, basestring):
|
|
||||||
value = [value]
|
|
||||||
else:
|
|
||||||
if isinstance(value, text_type):
|
|
||||||
value = [value]
|
|
||||||
|
|
||||||
func = dict_match(self.Set, key)
|
|
||||||
if func is not None:
|
|
||||||
return func(self.__mp4, key, value)
|
|
||||||
else:
|
|
||||||
raise EasyMP4KeyError("%r is not a valid key" % key)
|
|
||||||
|
|
||||||
def __delitem__(self, key):
|
|
||||||
key = key.lower()
|
|
||||||
func = dict_match(self.Delete, key)
|
|
||||||
if func is not None:
|
|
||||||
return func(self.__mp4, key)
|
|
||||||
else:
|
|
||||||
raise EasyMP4KeyError("%r is not a valid key" % key)
|
|
||||||
|
|
||||||
def keys(self):
|
|
||||||
keys = []
|
|
||||||
for key in self.Get.keys():
|
|
||||||
if key in self.List:
|
|
||||||
keys.extend(self.List[key](self.__mp4, key))
|
|
||||||
elif key in self:
|
|
||||||
keys.append(key)
|
|
||||||
return keys
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
"""Print tag key=value pairs."""
|
|
||||||
strings = []
|
|
||||||
for key in sorted(self.keys()):
|
|
||||||
values = self[key]
|
|
||||||
for value in values:
|
|
||||||
strings.append("%s=%s" % (key, value))
|
|
||||||
return "\n".join(strings)
|
|
||||||
|
|
||||||
for atomid, key in {
|
|
||||||
'\xa9nam': 'title',
|
|
||||||
'\xa9alb': 'album',
|
|
||||||
'\xa9ART': 'artist',
|
|
||||||
'aART': 'albumartist',
|
|
||||||
'\xa9day': 'date',
|
|
||||||
'\xa9cmt': 'comment',
|
|
||||||
'desc': 'description',
|
|
||||||
'\xa9grp': 'grouping',
|
|
||||||
'\xa9gen': 'genre',
|
|
||||||
'cprt': 'copyright',
|
|
||||||
'soal': 'albumsort',
|
|
||||||
'soaa': 'albumartistsort',
|
|
||||||
'soar': 'artistsort',
|
|
||||||
'sonm': 'titlesort',
|
|
||||||
'soco': 'composersort',
|
|
||||||
}.items():
|
|
||||||
EasyMP4Tags.RegisterTextKey(key, atomid)
|
|
||||||
|
|
||||||
for name, key in {
|
|
||||||
'MusicBrainz Artist Id': 'musicbrainz_artistid',
|
|
||||||
'MusicBrainz Track Id': 'musicbrainz_trackid',
|
|
||||||
'MusicBrainz Album Id': 'musicbrainz_albumid',
|
|
||||||
'MusicBrainz Album Artist Id': 'musicbrainz_albumartistid',
|
|
||||||
'MusicIP PUID': 'musicip_puid',
|
|
||||||
'MusicBrainz Album Status': 'musicbrainz_albumstatus',
|
|
||||||
'MusicBrainz Album Type': 'musicbrainz_albumtype',
|
|
||||||
'MusicBrainz Release Country': 'releasecountry',
|
|
||||||
}.items():
|
|
||||||
EasyMP4Tags.RegisterFreeformKey(key, name)
|
|
||||||
|
|
||||||
for name, key in {
|
|
||||||
"tmpo": "bpm",
|
|
||||||
}.items():
|
|
||||||
EasyMP4Tags.RegisterIntKey(key, name)
|
|
||||||
|
|
||||||
for name, key in {
|
|
||||||
"trkn": "tracknumber",
|
|
||||||
"disk": "discnumber",
|
|
||||||
}.items():
|
|
||||||
EasyMP4Tags.RegisterIntPairKey(key, name)
|
|
||||||
|
|
||||||
|
|
||||||
class EasyMP4(MP4):
|
|
||||||
"""Like :class:`MP4 <mutagen.mp4.MP4>`,
|
|
||||||
but uses :class:`EasyMP4Tags` for tags.
|
|
||||||
|
|
||||||
:ivar info: :class:`MP4Info <mutagen.mp4.MP4Info>`
|
|
||||||
:ivar tags: :class:`EasyMP4Tags`
|
|
||||||
"""
|
|
||||||
|
|
||||||
MP4Tags = EasyMP4Tags
|
|
||||||
|
|
||||||
Get = EasyMP4Tags.Get
|
|
||||||
Set = EasyMP4Tags.Set
|
|
||||||
Delete = EasyMP4Tags.Delete
|
|
||||||
List = EasyMP4Tags.List
|
|
||||||
RegisterTextKey = EasyMP4Tags.RegisterTextKey
|
|
||||||
RegisterKey = EasyMP4Tags.RegisterKey
|
|
|
@ -1,876 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2005 Joe Wreschnig
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of version 2 of the GNU General Public License as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""Read and write FLAC Vorbis comments and stream information.
|
|
||||||
|
|
||||||
Read more about FLAC at http://flac.sourceforge.net.
|
|
||||||
|
|
||||||
FLAC supports arbitrary metadata blocks. The two most interesting ones
|
|
||||||
are the FLAC stream information block, and the Vorbis comment block;
|
|
||||||
these are also the only ones Mutagen can currently read.
|
|
||||||
|
|
||||||
This module does not handle Ogg FLAC files.
|
|
||||||
|
|
||||||
Based off documentation available at
|
|
||||||
http://flac.sourceforge.net/format.html
|
|
||||||
"""
|
|
||||||
|
|
||||||
__all__ = ["FLAC", "Open", "delete"]
|
|
||||||
|
|
||||||
import struct
|
|
||||||
from ._vorbis import VCommentDict
|
|
||||||
import mutagen
|
|
||||||
|
|
||||||
from ._compat import cBytesIO, endswith, chr_, xrange
|
|
||||||
from mutagen._util import resize_bytes, MutagenError, get_size
|
|
||||||
from mutagen._tags import PaddingInfo
|
|
||||||
from mutagen.id3 import BitPaddedInt
|
|
||||||
from functools import reduce
|
|
||||||
|
|
||||||
|
|
||||||
class error(IOError, MutagenError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class FLACNoHeaderError(error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class FLACVorbisError(ValueError, error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def to_int_be(data):
|
|
||||||
"""Convert an arbitrarily-long string to a long using big-endian
|
|
||||||
byte order."""
|
|
||||||
return reduce(lambda a, b: (a << 8) + b, bytearray(data), 0)
|
|
||||||
|
|
||||||
|
|
||||||
class StrictFileObject(object):
|
|
||||||
"""Wraps a file-like object and raises an exception if the requested
|
|
||||||
amount of data to read isn't returned."""
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
self._fileobj = fileobj
|
|
||||||
for m in ["close", "tell", "seek", "write", "name"]:
|
|
||||||
if hasattr(fileobj, m):
|
|
||||||
setattr(self, m, getattr(fileobj, m))
|
|
||||||
|
|
||||||
def read(self, size=-1):
|
|
||||||
data = self._fileobj.read(size)
|
|
||||||
if size >= 0 and len(data) != size:
|
|
||||||
raise error("file said %d bytes, read %d bytes" % (
|
|
||||||
size, len(data)))
|
|
||||||
return data
|
|
||||||
|
|
||||||
def tryread(self, *args):
|
|
||||||
return self._fileobj.read(*args)
|
|
||||||
|
|
||||||
|
|
||||||
class MetadataBlock(object):
|
|
||||||
"""A generic block of FLAC metadata.
|
|
||||||
|
|
||||||
This class is extended by specific used as an ancestor for more specific
|
|
||||||
blocks, and also as a container for data blobs of unknown blocks.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
* data -- raw binary data for this block
|
|
||||||
"""
|
|
||||||
|
|
||||||
_distrust_size = False
|
|
||||||
"""For block types setting this, we don't trust the size field and
|
|
||||||
use the size of the content instead."""
|
|
||||||
|
|
||||||
_invalid_overflow_size = -1
|
|
||||||
"""In case the real size was bigger than what is representable by the
|
|
||||||
24 bit size field, we save the wrong specified size here. This can
|
|
||||||
only be set if _distrust_size is True"""
|
|
||||||
|
|
||||||
_MAX_SIZE = 2 ** 24 - 1
|
|
||||||
|
|
||||||
def __init__(self, data):
|
|
||||||
"""Parse the given data string or file-like as a metadata block.
|
|
||||||
The metadata header should not be included."""
|
|
||||||
if data is not None:
|
|
||||||
if not isinstance(data, StrictFileObject):
|
|
||||||
if isinstance(data, bytes):
|
|
||||||
data = cBytesIO(data)
|
|
||||||
elif not hasattr(data, 'read'):
|
|
||||||
raise TypeError(
|
|
||||||
"StreamInfo requires string data or a file-like")
|
|
||||||
data = StrictFileObject(data)
|
|
||||||
self.load(data)
|
|
||||||
|
|
||||||
def load(self, data):
|
|
||||||
self.data = data.read()
|
|
||||||
|
|
||||||
def write(self):
|
|
||||||
return self.data
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _writeblock(cls, block, is_last=False):
|
|
||||||
"""Returns the block content + header.
|
|
||||||
|
|
||||||
Raises error.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = bytearray()
|
|
||||||
code = (block.code | 128) if is_last else block.code
|
|
||||||
datum = block.write()
|
|
||||||
size = len(datum)
|
|
||||||
if size > cls._MAX_SIZE:
|
|
||||||
if block._distrust_size and block._invalid_overflow_size != -1:
|
|
||||||
# The original size of this block was (1) wrong and (2)
|
|
||||||
# the real size doesn't allow us to save the file
|
|
||||||
# according to the spec (too big for 24 bit uint). Instead
|
|
||||||
# simply write back the original wrong size.. at least
|
|
||||||
# we don't make the file more "broken" as it is.
|
|
||||||
size = block._invalid_overflow_size
|
|
||||||
else:
|
|
||||||
raise error("block is too long to write")
|
|
||||||
assert not size > cls._MAX_SIZE
|
|
||||||
length = struct.pack(">I", size)[-3:]
|
|
||||||
data.append(code)
|
|
||||||
data += length
|
|
||||||
data += datum
|
|
||||||
return data
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _writeblocks(cls, blocks, available, cont_size, padding_func):
|
|
||||||
"""Render metadata block as a byte string."""
|
|
||||||
|
|
||||||
# write everything except padding
|
|
||||||
data = bytearray()
|
|
||||||
for block in blocks:
|
|
||||||
if isinstance(block, Padding):
|
|
||||||
continue
|
|
||||||
data += cls._writeblock(block)
|
|
||||||
blockssize = len(data)
|
|
||||||
|
|
||||||
# take the padding overhead into account. we always add one
|
|
||||||
# to make things simple.
|
|
||||||
padding_block = Padding()
|
|
||||||
blockssize += len(cls._writeblock(padding_block))
|
|
||||||
|
|
||||||
# finally add a padding block
|
|
||||||
info = PaddingInfo(available - blockssize, cont_size)
|
|
||||||
padding_block.length = min(info._get_padding(padding_func),
|
|
||||||
cls._MAX_SIZE)
|
|
||||||
data += cls._writeblock(padding_block, is_last=True)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class StreamInfo(MetadataBlock, mutagen.StreamInfo):
|
|
||||||
"""FLAC stream information.
|
|
||||||
|
|
||||||
This contains information about the audio data in the FLAC file.
|
|
||||||
Unlike most stream information objects in Mutagen, changes to this
|
|
||||||
one will rewritten to the file when it is saved. Unless you are
|
|
||||||
actually changing the audio stream itself, don't change any
|
|
||||||
attributes of this block.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
* min_blocksize -- minimum audio block size
|
|
||||||
* max_blocksize -- maximum audio block size
|
|
||||||
* sample_rate -- audio sample rate in Hz
|
|
||||||
* channels -- audio channels (1 for mono, 2 for stereo)
|
|
||||||
* bits_per_sample -- bits per sample
|
|
||||||
* total_samples -- total samples in file
|
|
||||||
* length -- audio length in seconds
|
|
||||||
"""
|
|
||||||
|
|
||||||
code = 0
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
try:
|
|
||||||
return (self.min_blocksize == other.min_blocksize and
|
|
||||||
self.max_blocksize == other.max_blocksize and
|
|
||||||
self.sample_rate == other.sample_rate and
|
|
||||||
self.channels == other.channels and
|
|
||||||
self.bits_per_sample == other.bits_per_sample and
|
|
||||||
self.total_samples == other.total_samples)
|
|
||||||
except:
|
|
||||||
return False
|
|
||||||
|
|
||||||
__hash__ = MetadataBlock.__hash__
|
|
||||||
|
|
||||||
def load(self, data):
|
|
||||||
self.min_blocksize = int(to_int_be(data.read(2)))
|
|
||||||
self.max_blocksize = int(to_int_be(data.read(2)))
|
|
||||||
self.min_framesize = int(to_int_be(data.read(3)))
|
|
||||||
self.max_framesize = int(to_int_be(data.read(3)))
|
|
||||||
# first 16 bits of sample rate
|
|
||||||
sample_first = to_int_be(data.read(2))
|
|
||||||
# last 4 bits of sample rate, 3 of channels, first 1 of bits/sample
|
|
||||||
sample_channels_bps = to_int_be(data.read(1))
|
|
||||||
# last 4 of bits/sample, 36 of total samples
|
|
||||||
bps_total = to_int_be(data.read(5))
|
|
||||||
|
|
||||||
sample_tail = sample_channels_bps >> 4
|
|
||||||
self.sample_rate = int((sample_first << 4) + sample_tail)
|
|
||||||
if not self.sample_rate:
|
|
||||||
raise error("A sample rate value of 0 is invalid")
|
|
||||||
self.channels = int(((sample_channels_bps >> 1) & 7) + 1)
|
|
||||||
bps_tail = bps_total >> 36
|
|
||||||
bps_head = (sample_channels_bps & 1) << 4
|
|
||||||
self.bits_per_sample = int(bps_head + bps_tail + 1)
|
|
||||||
self.total_samples = bps_total & 0xFFFFFFFFF
|
|
||||||
self.length = self.total_samples / float(self.sample_rate)
|
|
||||||
|
|
||||||
self.md5_signature = to_int_be(data.read(16))
|
|
||||||
|
|
||||||
def write(self):
|
|
||||||
f = cBytesIO()
|
|
||||||
f.write(struct.pack(">I", self.min_blocksize)[-2:])
|
|
||||||
f.write(struct.pack(">I", self.max_blocksize)[-2:])
|
|
||||||
f.write(struct.pack(">I", self.min_framesize)[-3:])
|
|
||||||
f.write(struct.pack(">I", self.max_framesize)[-3:])
|
|
||||||
|
|
||||||
# first 16 bits of sample rate
|
|
||||||
f.write(struct.pack(">I", self.sample_rate >> 4)[-2:])
|
|
||||||
# 4 bits sample, 3 channel, 1 bps
|
|
||||||
byte = (self.sample_rate & 0xF) << 4
|
|
||||||
byte += ((self.channels - 1) & 7) << 1
|
|
||||||
byte += ((self.bits_per_sample - 1) >> 4) & 1
|
|
||||||
f.write(chr_(byte))
|
|
||||||
# 4 bits of bps, 4 of sample count
|
|
||||||
byte = ((self.bits_per_sample - 1) & 0xF) << 4
|
|
||||||
byte += (self.total_samples >> 32) & 0xF
|
|
||||||
f.write(chr_(byte))
|
|
||||||
# last 32 of sample count
|
|
||||||
f.write(struct.pack(">I", self.total_samples & 0xFFFFFFFF))
|
|
||||||
# MD5 signature
|
|
||||||
sig = self.md5_signature
|
|
||||||
f.write(struct.pack(
|
|
||||||
">4I", (sig >> 96) & 0xFFFFFFFF, (sig >> 64) & 0xFFFFFFFF,
|
|
||||||
(sig >> 32) & 0xFFFFFFFF, sig & 0xFFFFFFFF))
|
|
||||||
return f.getvalue()
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
return u"FLAC, %.2f seconds, %d Hz" % (self.length, self.sample_rate)
|
|
||||||
|
|
||||||
|
|
||||||
class SeekPoint(tuple):
|
|
||||||
"""A single seek point in a FLAC file.
|
|
||||||
|
|
||||||
Placeholder seek points have first_sample of 0xFFFFFFFFFFFFFFFFL,
|
|
||||||
and byte_offset and num_samples undefined. Seek points must be
|
|
||||||
sorted in ascending order by first_sample number. Seek points must
|
|
||||||
be unique by first_sample number, except for placeholder
|
|
||||||
points. Placeholder points must occur last in the table and there
|
|
||||||
may be any number of them.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
* first_sample -- sample number of first sample in the target frame
|
|
||||||
* byte_offset -- offset from first frame to target frame
|
|
||||||
* num_samples -- number of samples in target frame
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __new__(cls, first_sample, byte_offset, num_samples):
|
|
||||||
return super(cls, SeekPoint).__new__(
|
|
||||||
cls, (first_sample, byte_offset, num_samples))
|
|
||||||
|
|
||||||
first_sample = property(lambda self: self[0])
|
|
||||||
byte_offset = property(lambda self: self[1])
|
|
||||||
num_samples = property(lambda self: self[2])
|
|
||||||
|
|
||||||
|
|
||||||
class SeekTable(MetadataBlock):
|
|
||||||
"""Read and write FLAC seek tables.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
* seekpoints -- list of SeekPoint objects
|
|
||||||
"""
|
|
||||||
|
|
||||||
__SEEKPOINT_FORMAT = '>QQH'
|
|
||||||
__SEEKPOINT_SIZE = struct.calcsize(__SEEKPOINT_FORMAT)
|
|
||||||
|
|
||||||
code = 3
|
|
||||||
|
|
||||||
def __init__(self, data):
|
|
||||||
self.seekpoints = []
|
|
||||||
super(SeekTable, self).__init__(data)
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
try:
|
|
||||||
return (self.seekpoints == other.seekpoints)
|
|
||||||
except (AttributeError, TypeError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
__hash__ = MetadataBlock.__hash__
|
|
||||||
|
|
||||||
def load(self, data):
|
|
||||||
self.seekpoints = []
|
|
||||||
sp = data.tryread(self.__SEEKPOINT_SIZE)
|
|
||||||
while len(sp) == self.__SEEKPOINT_SIZE:
|
|
||||||
self.seekpoints.append(SeekPoint(
|
|
||||||
*struct.unpack(self.__SEEKPOINT_FORMAT, sp)))
|
|
||||||
sp = data.tryread(self.__SEEKPOINT_SIZE)
|
|
||||||
|
|
||||||
def write(self):
|
|
||||||
f = cBytesIO()
|
|
||||||
for seekpoint in self.seekpoints:
|
|
||||||
packed = struct.pack(
|
|
||||||
self.__SEEKPOINT_FORMAT,
|
|
||||||
seekpoint.first_sample, seekpoint.byte_offset,
|
|
||||||
seekpoint.num_samples)
|
|
||||||
f.write(packed)
|
|
||||||
return f.getvalue()
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return "<%s seekpoints=%r>" % (type(self).__name__, self.seekpoints)
|
|
||||||
|
|
||||||
|
|
||||||
class VCFLACDict(VCommentDict):
|
|
||||||
"""Read and write FLAC Vorbis comments.
|
|
||||||
|
|
||||||
FLACs don't use the framing bit at the end of the comment block.
|
|
||||||
So this extends VCommentDict to not use the framing bit.
|
|
||||||
"""
|
|
||||||
|
|
||||||
code = 4
|
|
||||||
_distrust_size = True
|
|
||||||
|
|
||||||
def load(self, data, errors='replace', framing=False):
|
|
||||||
super(VCFLACDict, self).load(data, errors=errors, framing=framing)
|
|
||||||
|
|
||||||
def write(self, framing=False):
|
|
||||||
return super(VCFLACDict, self).write(framing=framing)
|
|
||||||
|
|
||||||
|
|
||||||
class CueSheetTrackIndex(tuple):
|
|
||||||
"""Index for a track in a cuesheet.
|
|
||||||
|
|
||||||
For CD-DA, an index_number of 0 corresponds to the track
|
|
||||||
pre-gap. The first index in a track must have a number of 0 or 1,
|
|
||||||
and subsequently, index_numbers must increase by 1. Index_numbers
|
|
||||||
must be unique within a track. And index_offset must be evenly
|
|
||||||
divisible by 588 samples.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
* index_number -- index point number
|
|
||||||
* index_offset -- offset in samples from track start
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __new__(cls, index_number, index_offset):
|
|
||||||
return super(cls, CueSheetTrackIndex).__new__(
|
|
||||||
cls, (index_number, index_offset))
|
|
||||||
|
|
||||||
index_number = property(lambda self: self[0])
|
|
||||||
index_offset = property(lambda self: self[1])
|
|
||||||
|
|
||||||
|
|
||||||
class CueSheetTrack(object):
|
|
||||||
"""A track in a cuesheet.
|
|
||||||
|
|
||||||
For CD-DA, track_numbers must be 1-99, or 170 for the
|
|
||||||
lead-out. Track_numbers must be unique within a cue sheet. There
|
|
||||||
must be atleast one index in every track except the lead-out track
|
|
||||||
which must have none.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
* track_number -- track number
|
|
||||||
* start_offset -- track offset in samples from start of FLAC stream
|
|
||||||
* isrc -- ISRC code
|
|
||||||
* type -- 0 for audio, 1 for digital data
|
|
||||||
* pre_emphasis -- true if the track is recorded with pre-emphasis
|
|
||||||
* indexes -- list of CueSheetTrackIndex objects
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, track_number, start_offset, isrc='', type_=0,
|
|
||||||
pre_emphasis=False):
|
|
||||||
self.track_number = track_number
|
|
||||||
self.start_offset = start_offset
|
|
||||||
self.isrc = isrc
|
|
||||||
self.type = type_
|
|
||||||
self.pre_emphasis = pre_emphasis
|
|
||||||
self.indexes = []
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
try:
|
|
||||||
return (self.track_number == other.track_number and
|
|
||||||
self.start_offset == other.start_offset and
|
|
||||||
self.isrc == other.isrc and
|
|
||||||
self.type == other.type and
|
|
||||||
self.pre_emphasis == other.pre_emphasis and
|
|
||||||
self.indexes == other.indexes)
|
|
||||||
except (AttributeError, TypeError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
__hash__ = object.__hash__
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return (("<%s number=%r, offset=%d, isrc=%r, type=%r, "
|
|
||||||
"pre_emphasis=%r, indexes=%r)>") %
|
|
||||||
(type(self).__name__, self.track_number, self.start_offset,
|
|
||||||
self.isrc, self.type, self.pre_emphasis, self.indexes))
|
|
||||||
|
|
||||||
|
|
||||||
class CueSheet(MetadataBlock):
|
|
||||||
"""Read and write FLAC embedded cue sheets.
|
|
||||||
|
|
||||||
Number of tracks should be from 1 to 100. There should always be
|
|
||||||
exactly one lead-out track and that track must be the last track
|
|
||||||
in the cue sheet.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
* media_catalog_number -- media catalog number in ASCII
|
|
||||||
* lead_in_samples -- number of lead-in samples
|
|
||||||
* compact_disc -- true if the cuesheet corresponds to a compact disc
|
|
||||||
* tracks -- list of CueSheetTrack objects
|
|
||||||
* lead_out -- lead-out as CueSheetTrack or None if lead-out was not found
|
|
||||||
"""
|
|
||||||
|
|
||||||
__CUESHEET_FORMAT = '>128sQB258xB'
|
|
||||||
__CUESHEET_SIZE = struct.calcsize(__CUESHEET_FORMAT)
|
|
||||||
__CUESHEET_TRACK_FORMAT = '>QB12sB13xB'
|
|
||||||
__CUESHEET_TRACK_SIZE = struct.calcsize(__CUESHEET_TRACK_FORMAT)
|
|
||||||
__CUESHEET_TRACKINDEX_FORMAT = '>QB3x'
|
|
||||||
__CUESHEET_TRACKINDEX_SIZE = struct.calcsize(__CUESHEET_TRACKINDEX_FORMAT)
|
|
||||||
|
|
||||||
code = 5
|
|
||||||
|
|
||||||
media_catalog_number = b''
|
|
||||||
lead_in_samples = 88200
|
|
||||||
compact_disc = True
|
|
||||||
|
|
||||||
def __init__(self, data):
|
|
||||||
self.tracks = []
|
|
||||||
super(CueSheet, self).__init__(data)
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
try:
|
|
||||||
return (self.media_catalog_number == other.media_catalog_number and
|
|
||||||
self.lead_in_samples == other.lead_in_samples and
|
|
||||||
self.compact_disc == other.compact_disc and
|
|
||||||
self.tracks == other.tracks)
|
|
||||||
except (AttributeError, TypeError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
__hash__ = MetadataBlock.__hash__
|
|
||||||
|
|
||||||
def load(self, data):
|
|
||||||
header = data.read(self.__CUESHEET_SIZE)
|
|
||||||
media_catalog_number, lead_in_samples, flags, num_tracks = \
|
|
||||||
struct.unpack(self.__CUESHEET_FORMAT, header)
|
|
||||||
self.media_catalog_number = media_catalog_number.rstrip(b'\0')
|
|
||||||
self.lead_in_samples = lead_in_samples
|
|
||||||
self.compact_disc = bool(flags & 0x80)
|
|
||||||
self.tracks = []
|
|
||||||
for i in xrange(num_tracks):
|
|
||||||
track = data.read(self.__CUESHEET_TRACK_SIZE)
|
|
||||||
start_offset, track_number, isrc_padded, flags, num_indexes = \
|
|
||||||
struct.unpack(self.__CUESHEET_TRACK_FORMAT, track)
|
|
||||||
isrc = isrc_padded.rstrip(b'\0')
|
|
||||||
type_ = (flags & 0x80) >> 7
|
|
||||||
pre_emphasis = bool(flags & 0x40)
|
|
||||||
val = CueSheetTrack(
|
|
||||||
track_number, start_offset, isrc, type_, pre_emphasis)
|
|
||||||
for j in xrange(num_indexes):
|
|
||||||
index = data.read(self.__CUESHEET_TRACKINDEX_SIZE)
|
|
||||||
index_offset, index_number = struct.unpack(
|
|
||||||
self.__CUESHEET_TRACKINDEX_FORMAT, index)
|
|
||||||
val.indexes.append(
|
|
||||||
CueSheetTrackIndex(index_number, index_offset))
|
|
||||||
self.tracks.append(val)
|
|
||||||
|
|
||||||
def write(self):
|
|
||||||
f = cBytesIO()
|
|
||||||
flags = 0
|
|
||||||
if self.compact_disc:
|
|
||||||
flags |= 0x80
|
|
||||||
packed = struct.pack(
|
|
||||||
self.__CUESHEET_FORMAT, self.media_catalog_number,
|
|
||||||
self.lead_in_samples, flags, len(self.tracks))
|
|
||||||
f.write(packed)
|
|
||||||
for track in self.tracks:
|
|
||||||
track_flags = 0
|
|
||||||
track_flags |= (track.type & 1) << 7
|
|
||||||
if track.pre_emphasis:
|
|
||||||
track_flags |= 0x40
|
|
||||||
track_packed = struct.pack(
|
|
||||||
self.__CUESHEET_TRACK_FORMAT, track.start_offset,
|
|
||||||
track.track_number, track.isrc, track_flags,
|
|
||||||
len(track.indexes))
|
|
||||||
f.write(track_packed)
|
|
||||||
for index in track.indexes:
|
|
||||||
index_packed = struct.pack(
|
|
||||||
self.__CUESHEET_TRACKINDEX_FORMAT,
|
|
||||||
index.index_offset, index.index_number)
|
|
||||||
f.write(index_packed)
|
|
||||||
return f.getvalue()
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return (("<%s media_catalog_number=%r, lead_in=%r, compact_disc=%r, "
|
|
||||||
"tracks=%r>") %
|
|
||||||
(type(self).__name__, self.media_catalog_number,
|
|
||||||
self.lead_in_samples, self.compact_disc, self.tracks))
|
|
||||||
|
|
||||||
|
|
||||||
class Picture(MetadataBlock):
|
|
||||||
"""Read and write FLAC embed pictures.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
* type -- picture type (same as types for ID3 APIC frames)
|
|
||||||
* mime -- MIME type of the picture
|
|
||||||
* desc -- picture's description
|
|
||||||
* width -- width in pixels
|
|
||||||
* height -- height in pixels
|
|
||||||
* depth -- color depth in bits-per-pixel
|
|
||||||
* colors -- number of colors for indexed palettes (like GIF),
|
|
||||||
0 for non-indexed
|
|
||||||
* data -- picture data
|
|
||||||
|
|
||||||
To create a picture from file (in order to add to a FLAC file),
|
|
||||||
instantiate this object without passing anything to the constructor and
|
|
||||||
then set the properties manually::
|
|
||||||
|
|
||||||
p = Picture()
|
|
||||||
|
|
||||||
with open("Folder.jpg", "rb") as f:
|
|
||||||
pic.data = f.read()
|
|
||||||
|
|
||||||
pic.type = id3.PictureType.COVER_FRONT
|
|
||||||
pic.mime = u"image/jpeg"
|
|
||||||
pic.width = 500
|
|
||||||
pic.height = 500
|
|
||||||
pic.depth = 16 # color depth
|
|
||||||
"""
|
|
||||||
|
|
||||||
code = 6
|
|
||||||
_distrust_size = True
|
|
||||||
|
|
||||||
def __init__(self, data=None):
|
|
||||||
self.type = 0
|
|
||||||
self.mime = u''
|
|
||||||
self.desc = u''
|
|
||||||
self.width = 0
|
|
||||||
self.height = 0
|
|
||||||
self.depth = 0
|
|
||||||
self.colors = 0
|
|
||||||
self.data = b''
|
|
||||||
super(Picture, self).__init__(data)
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
try:
|
|
||||||
return (self.type == other.type and
|
|
||||||
self.mime == other.mime and
|
|
||||||
self.desc == other.desc and
|
|
||||||
self.width == other.width and
|
|
||||||
self.height == other.height and
|
|
||||||
self.depth == other.depth and
|
|
||||||
self.colors == other.colors and
|
|
||||||
self.data == other.data)
|
|
||||||
except (AttributeError, TypeError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
__hash__ = MetadataBlock.__hash__
|
|
||||||
|
|
||||||
def load(self, data):
|
|
||||||
self.type, length = struct.unpack('>2I', data.read(8))
|
|
||||||
self.mime = data.read(length).decode('UTF-8', 'replace')
|
|
||||||
length, = struct.unpack('>I', data.read(4))
|
|
||||||
self.desc = data.read(length).decode('UTF-8', 'replace')
|
|
||||||
(self.width, self.height, self.depth,
|
|
||||||
self.colors, length) = struct.unpack('>5I', data.read(20))
|
|
||||||
self.data = data.read(length)
|
|
||||||
|
|
||||||
def write(self):
|
|
||||||
f = cBytesIO()
|
|
||||||
mime = self.mime.encode('UTF-8')
|
|
||||||
f.write(struct.pack('>2I', self.type, len(mime)))
|
|
||||||
f.write(mime)
|
|
||||||
desc = self.desc.encode('UTF-8')
|
|
||||||
f.write(struct.pack('>I', len(desc)))
|
|
||||||
f.write(desc)
|
|
||||||
f.write(struct.pack('>5I', self.width, self.height, self.depth,
|
|
||||||
self.colors, len(self.data)))
|
|
||||||
f.write(self.data)
|
|
||||||
return f.getvalue()
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return "<%s '%s' (%d bytes)>" % (type(self).__name__, self.mime,
|
|
||||||
len(self.data))
|
|
||||||
|
|
||||||
|
|
||||||
class Padding(MetadataBlock):
|
|
||||||
"""Empty padding space for metadata blocks.
|
|
||||||
|
|
||||||
To avoid rewriting the entire FLAC file when editing comments,
|
|
||||||
metadata is often padded. Padding should occur at the end, and no
|
|
||||||
more than one padding block should be in any FLAC file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
code = 1
|
|
||||||
|
|
||||||
def __init__(self, data=b""):
|
|
||||||
super(Padding, self).__init__(data)
|
|
||||||
|
|
||||||
def load(self, data):
|
|
||||||
self.length = len(data.read())
|
|
||||||
|
|
||||||
def write(self):
|
|
||||||
try:
|
|
||||||
return b"\x00" * self.length
|
|
||||||
# On some 64 bit platforms this won't generate a MemoryError
|
|
||||||
# or OverflowError since you might have enough RAM, but it
|
|
||||||
# still generates a ValueError. On other 64 bit platforms,
|
|
||||||
# this will still succeed for extremely large values.
|
|
||||||
# Those should never happen in the real world, and if they
|
|
||||||
# do, writeblocks will catch it.
|
|
||||||
except (OverflowError, ValueError, MemoryError):
|
|
||||||
raise error("cannot write %d bytes" % self.length)
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
return isinstance(other, Padding) and self.length == other.length
|
|
||||||
|
|
||||||
__hash__ = MetadataBlock.__hash__
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return "<%s (%d bytes)>" % (type(self).__name__, self.length)
|
|
||||||
|
|
||||||
|
|
||||||
class FLAC(mutagen.FileType):
|
|
||||||
"""A FLAC audio file.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
* cuesheet -- CueSheet object, if any
|
|
||||||
* seektable -- SeekTable object, if any
|
|
||||||
* pictures -- list of embedded pictures
|
|
||||||
"""
|
|
||||||
|
|
||||||
_mimes = ["audio/x-flac", "application/x-flac"]
|
|
||||||
|
|
||||||
info = None
|
|
||||||
"""A `StreamInfo`"""
|
|
||||||
|
|
||||||
tags = None
|
|
||||||
"""A `VCommentDict`"""
|
|
||||||
|
|
||||||
METADATA_BLOCKS = [StreamInfo, Padding, None, SeekTable, VCFLACDict,
|
|
||||||
CueSheet, Picture]
|
|
||||||
"""Known metadata block types, indexed by ID."""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score(filename, fileobj, header_data):
|
|
||||||
return (header_data.startswith(b"fLaC") +
|
|
||||||
endswith(filename.lower(), ".flac") * 3)
|
|
||||||
|
|
||||||
def __read_metadata_block(self, fileobj):
|
|
||||||
byte = ord(fileobj.read(1))
|
|
||||||
size = to_int_be(fileobj.read(3))
|
|
||||||
code = byte & 0x7F
|
|
||||||
last_block = bool(byte & 0x80)
|
|
||||||
|
|
||||||
try:
|
|
||||||
block_type = self.METADATA_BLOCKS[code] or MetadataBlock
|
|
||||||
except IndexError:
|
|
||||||
block_type = MetadataBlock
|
|
||||||
|
|
||||||
if block_type._distrust_size:
|
|
||||||
# Some jackass is writing broken Metadata block length
|
|
||||||
# for Vorbis comment blocks, and the FLAC reference
|
|
||||||
# implementaton can parse them (mostly by accident),
|
|
||||||
# so we have to too. Instead of parsing the size
|
|
||||||
# given, parse an actual Vorbis comment, leaving
|
|
||||||
# fileobj in the right position.
|
|
||||||
# http://code.google.com/p/mutagen/issues/detail?id=52
|
|
||||||
# ..same for the Picture block:
|
|
||||||
# http://code.google.com/p/mutagen/issues/detail?id=106
|
|
||||||
start = fileobj.tell()
|
|
||||||
block = block_type(fileobj)
|
|
||||||
real_size = fileobj.tell() - start
|
|
||||||
if real_size > MetadataBlock._MAX_SIZE:
|
|
||||||
block._invalid_overflow_size = size
|
|
||||||
else:
|
|
||||||
data = fileobj.read(size)
|
|
||||||
block = block_type(data)
|
|
||||||
block.code = code
|
|
||||||
|
|
||||||
if block.code == VCFLACDict.code:
|
|
||||||
if self.tags is None:
|
|
||||||
self.tags = block
|
|
||||||
else:
|
|
||||||
raise FLACVorbisError("> 1 Vorbis comment block found")
|
|
||||||
elif block.code == CueSheet.code:
|
|
||||||
if self.cuesheet is None:
|
|
||||||
self.cuesheet = block
|
|
||||||
else:
|
|
||||||
raise error("> 1 CueSheet block found")
|
|
||||||
elif block.code == SeekTable.code:
|
|
||||||
if self.seektable is None:
|
|
||||||
self.seektable = block
|
|
||||||
else:
|
|
||||||
raise error("> 1 SeekTable block found")
|
|
||||||
self.metadata_blocks.append(block)
|
|
||||||
return not last_block
|
|
||||||
|
|
||||||
def add_tags(self):
|
|
||||||
"""Add a Vorbis comment block to the file."""
|
|
||||||
if self.tags is None:
|
|
||||||
self.tags = VCFLACDict()
|
|
||||||
self.metadata_blocks.append(self.tags)
|
|
||||||
else:
|
|
||||||
raise FLACVorbisError("a Vorbis comment already exists")
|
|
||||||
|
|
||||||
add_vorbiscomment = add_tags
|
|
||||||
|
|
||||||
def delete(self, filename=None):
|
|
||||||
"""Remove Vorbis comments from a file.
|
|
||||||
|
|
||||||
If no filename is given, the one most recently loaded is used.
|
|
||||||
"""
|
|
||||||
if filename is None:
|
|
||||||
filename = self.filename
|
|
||||||
|
|
||||||
if self.tags is not None:
|
|
||||||
self.metadata_blocks.remove(self.tags)
|
|
||||||
self.save(padding=lambda x: 0)
|
|
||||||
self.metadata_blocks.append(self.tags)
|
|
||||||
self.tags.clear()
|
|
||||||
|
|
||||||
vc = property(lambda s: s.tags, doc="Alias for tags; don't use this.")
|
|
||||||
|
|
||||||
def load(self, filename):
|
|
||||||
"""Load file information from a filename."""
|
|
||||||
|
|
||||||
self.metadata_blocks = []
|
|
||||||
self.tags = None
|
|
||||||
self.cuesheet = None
|
|
||||||
self.seektable = None
|
|
||||||
self.filename = filename
|
|
||||||
fileobj = StrictFileObject(open(filename, "rb"))
|
|
||||||
try:
|
|
||||||
self.__check_header(fileobj)
|
|
||||||
while self.__read_metadata_block(fileobj):
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
fileobj.close()
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.metadata_blocks[0].length
|
|
||||||
except (AttributeError, IndexError):
|
|
||||||
raise FLACNoHeaderError("Stream info block not found")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def info(self):
|
|
||||||
return self.metadata_blocks[0]
|
|
||||||
|
|
||||||
def add_picture(self, picture):
|
|
||||||
"""Add a new picture to the file."""
|
|
||||||
self.metadata_blocks.append(picture)
|
|
||||||
|
|
||||||
def clear_pictures(self):
|
|
||||||
"""Delete all pictures from the file."""
|
|
||||||
|
|
||||||
blocks = [b for b in self.metadata_blocks if b.code != Picture.code]
|
|
||||||
self.metadata_blocks = blocks
|
|
||||||
|
|
||||||
@property
|
|
||||||
def pictures(self):
|
|
||||||
"""List of embedded pictures"""
|
|
||||||
|
|
||||||
return [b for b in self.metadata_blocks if b.code == Picture.code]
|
|
||||||
|
|
||||||
def save(self, filename=None, deleteid3=False, padding=None):
|
|
||||||
"""Save metadata blocks to a file.
|
|
||||||
|
|
||||||
If no filename is given, the one most recently loaded is used.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if filename is None:
|
|
||||||
filename = self.filename
|
|
||||||
|
|
||||||
with open(filename, 'rb+') as f:
|
|
||||||
header = self.__check_header(f)
|
|
||||||
audio_offset = self.__find_audio_offset(f)
|
|
||||||
# "fLaC" and maybe ID3
|
|
||||||
available = audio_offset - header
|
|
||||||
|
|
||||||
# Delete ID3v2
|
|
||||||
if deleteid3 and header > 4:
|
|
||||||
available += header - 4
|
|
||||||
header = 4
|
|
||||||
|
|
||||||
content_size = get_size(f) - audio_offset
|
|
||||||
assert content_size >= 0
|
|
||||||
data = MetadataBlock._writeblocks(
|
|
||||||
self.metadata_blocks, available, content_size, padding)
|
|
||||||
data_size = len(data)
|
|
||||||
|
|
||||||
resize_bytes(f, available, data_size, header)
|
|
||||||
f.seek(header - 4)
|
|
||||||
f.write(b"fLaC")
|
|
||||||
f.write(data)
|
|
||||||
|
|
||||||
# Delete ID3v1
|
|
||||||
if deleteid3:
|
|
||||||
try:
|
|
||||||
f.seek(-128, 2)
|
|
||||||
except IOError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if f.read(3) == b"TAG":
|
|
||||||
f.seek(-128, 2)
|
|
||||||
f.truncate()
|
|
||||||
|
|
||||||
def __find_audio_offset(self, fileobj):
|
|
||||||
byte = 0x00
|
|
||||||
while not (byte & 0x80):
|
|
||||||
byte = ord(fileobj.read(1))
|
|
||||||
size = to_int_be(fileobj.read(3))
|
|
||||||
try:
|
|
||||||
block_type = self.METADATA_BLOCKS[byte & 0x7F]
|
|
||||||
except IndexError:
|
|
||||||
block_type = None
|
|
||||||
|
|
||||||
if block_type and block_type._distrust_size:
|
|
||||||
# See comments in read_metadata_block; the size can't
|
|
||||||
# be trusted for Vorbis comment blocks and Picture block
|
|
||||||
block_type(fileobj)
|
|
||||||
else:
|
|
||||||
fileobj.read(size)
|
|
||||||
return fileobj.tell()
|
|
||||||
|
|
||||||
def __check_header(self, fileobj):
|
|
||||||
"""Returns the offset of the flac block start
|
|
||||||
(skipping id3 tags if found). The passed fileobj will be advanced to
|
|
||||||
that offset as well.
|
|
||||||
"""
|
|
||||||
|
|
||||||
size = 4
|
|
||||||
header = fileobj.read(4)
|
|
||||||
if header != b"fLaC":
|
|
||||||
size = None
|
|
||||||
if header[:3] == b"ID3":
|
|
||||||
size = 14 + BitPaddedInt(fileobj.read(6)[2:])
|
|
||||||
fileobj.seek(size - 4)
|
|
||||||
if fileobj.read(4) != b"fLaC":
|
|
||||||
size = None
|
|
||||||
if size is None:
|
|
||||||
raise FLACNoHeaderError(
|
|
||||||
"%r is not a valid FLAC file" % fileobj.name)
|
|
||||||
return size
|
|
||||||
|
|
||||||
|
|
||||||
Open = FLAC
|
|
||||||
|
|
||||||
|
|
||||||
def delete(filename):
|
|
||||||
"""Remove tags from a file."""
|
|
||||||
FLAC(filename).delete()
|
|
File diff suppressed because it is too large
Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load diff
|
@ -1,635 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2005 Michael Urman
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of version 2 of the GNU General Public License as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
import struct
|
|
||||||
from struct import unpack, pack
|
|
||||||
|
|
||||||
from .._compat import text_type, chr_, PY3, swap_to_string, string_types, \
|
|
||||||
xrange
|
|
||||||
from .._util import total_ordering, decode_terminated, enum, izip
|
|
||||||
from ._util import BitPaddedInt
|
|
||||||
|
|
||||||
|
|
||||||
@enum
|
|
||||||
class PictureType(object):
|
|
||||||
"""Enumeration of image types defined by the ID3 standard for the APIC
|
|
||||||
frame, but also reused in WMA/FLAC/VorbisComment.
|
|
||||||
"""
|
|
||||||
|
|
||||||
OTHER = 0
|
|
||||||
"""Other"""
|
|
||||||
|
|
||||||
FILE_ICON = 1
|
|
||||||
"""32x32 pixels 'file icon' (PNG only)"""
|
|
||||||
|
|
||||||
OTHER_FILE_ICON = 2
|
|
||||||
"""Other file icon"""
|
|
||||||
|
|
||||||
COVER_FRONT = 3
|
|
||||||
"""Cover (front)"""
|
|
||||||
|
|
||||||
COVER_BACK = 4
|
|
||||||
"""Cover (back)"""
|
|
||||||
|
|
||||||
LEAFLET_PAGE = 5
|
|
||||||
"""Leaflet page"""
|
|
||||||
|
|
||||||
MEDIA = 6
|
|
||||||
"""Media (e.g. label side of CD)"""
|
|
||||||
|
|
||||||
LEAD_ARTIST = 7
|
|
||||||
"""Lead artist/lead performer/soloist"""
|
|
||||||
|
|
||||||
ARTIST = 8
|
|
||||||
"""Artist/performer"""
|
|
||||||
|
|
||||||
CONDUCTOR = 9
|
|
||||||
"""Conductor"""
|
|
||||||
|
|
||||||
BAND = 10
|
|
||||||
"""Band/Orchestra"""
|
|
||||||
|
|
||||||
COMPOSER = 11
|
|
||||||
"""Composer"""
|
|
||||||
|
|
||||||
LYRICIST = 12
|
|
||||||
"""Lyricist/text writer"""
|
|
||||||
|
|
||||||
RECORDING_LOCATION = 13
|
|
||||||
"""Recording Location"""
|
|
||||||
|
|
||||||
DURING_RECORDING = 14
|
|
||||||
"""During recording"""
|
|
||||||
|
|
||||||
DURING_PERFORMANCE = 15
|
|
||||||
"""During performance"""
|
|
||||||
|
|
||||||
SCREEN_CAPTURE = 16
|
|
||||||
"""Movie/video screen capture"""
|
|
||||||
|
|
||||||
FISH = 17
|
|
||||||
"""A bright coloured fish"""
|
|
||||||
|
|
||||||
ILLUSTRATION = 18
|
|
||||||
"""Illustration"""
|
|
||||||
|
|
||||||
BAND_LOGOTYPE = 19
|
|
||||||
"""Band/artist logotype"""
|
|
||||||
|
|
||||||
PUBLISHER_LOGOTYPE = 20
|
|
||||||
"""Publisher/Studio logotype"""
|
|
||||||
|
|
||||||
|
|
||||||
class SpecError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Spec(object):
|
|
||||||
|
|
||||||
def __init__(self, name):
|
|
||||||
self.name = name
|
|
||||||
|
|
||||||
def __hash__(self):
|
|
||||||
raise TypeError("Spec objects are unhashable")
|
|
||||||
|
|
||||||
def _validate23(self, frame, value, **kwargs):
|
|
||||||
"""Return a possibly modified value which, if written,
|
|
||||||
results in valid id3v2.3 data.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
def read(self, frame, data):
|
|
||||||
"""Returns the (value, left_data) or raises SpecError"""
|
|
||||||
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def write(self, frame, value):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def validate(self, frame, value):
|
|
||||||
"""Returns the validated data or raises ValueError/TypeError"""
|
|
||||||
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class ByteSpec(Spec):
|
|
||||||
def read(self, frame, data):
|
|
||||||
return bytearray(data)[0], data[1:]
|
|
||||||
|
|
||||||
def write(self, frame, value):
|
|
||||||
return chr_(value)
|
|
||||||
|
|
||||||
def validate(self, frame, value):
|
|
||||||
if value is not None:
|
|
||||||
chr_(value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class IntegerSpec(Spec):
|
|
||||||
def read(self, frame, data):
|
|
||||||
return int(BitPaddedInt(data, bits=8)), b''
|
|
||||||
|
|
||||||
def write(self, frame, value):
|
|
||||||
return BitPaddedInt.to_str(value, bits=8, width=-1)
|
|
||||||
|
|
||||||
def validate(self, frame, value):
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class SizedIntegerSpec(Spec):
|
|
||||||
def __init__(self, name, size):
|
|
||||||
self.name, self.__sz = name, size
|
|
||||||
|
|
||||||
def read(self, frame, data):
|
|
||||||
return int(BitPaddedInt(data[:self.__sz], bits=8)), data[self.__sz:]
|
|
||||||
|
|
||||||
def write(self, frame, value):
|
|
||||||
return BitPaddedInt.to_str(value, bits=8, width=self.__sz)
|
|
||||||
|
|
||||||
def validate(self, frame, value):
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@enum
|
|
||||||
class Encoding(object):
|
|
||||||
"""Text Encoding"""
|
|
||||||
|
|
||||||
LATIN1 = 0
|
|
||||||
"""ISO-8859-1"""
|
|
||||||
|
|
||||||
UTF16 = 1
|
|
||||||
"""UTF-16 with BOM"""
|
|
||||||
|
|
||||||
UTF16BE = 2
|
|
||||||
"""UTF-16BE without BOM"""
|
|
||||||
|
|
||||||
UTF8 = 3
|
|
||||||
"""UTF-8"""
|
|
||||||
|
|
||||||
|
|
||||||
class EncodingSpec(ByteSpec):
|
|
||||||
|
|
||||||
def read(self, frame, data):
|
|
||||||
enc, data = super(EncodingSpec, self).read(frame, data)
|
|
||||||
if enc not in (Encoding.LATIN1, Encoding.UTF16, Encoding.UTF16BE,
|
|
||||||
Encoding.UTF8):
|
|
||||||
raise SpecError('Invalid Encoding: %r' % enc)
|
|
||||||
return enc, data
|
|
||||||
|
|
||||||
def validate(self, frame, value):
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
if value not in (Encoding.LATIN1, Encoding.UTF16, Encoding.UTF16BE,
|
|
||||||
Encoding.UTF8):
|
|
||||||
raise ValueError('Invalid Encoding: %r' % value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
def _validate23(self, frame, value, **kwargs):
|
|
||||||
# only 0, 1 are valid in v2.3, default to utf-16
|
|
||||||
if value not in (Encoding.LATIN1, Encoding.UTF16):
|
|
||||||
value = Encoding.UTF16
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class StringSpec(Spec):
|
|
||||||
"""A fixed size ASCII only payload."""
|
|
||||||
|
|
||||||
def __init__(self, name, length):
|
|
||||||
super(StringSpec, self).__init__(name)
|
|
||||||
self.len = length
|
|
||||||
|
|
||||||
def read(s, frame, data):
|
|
||||||
chunk = data[:s.len]
|
|
||||||
try:
|
|
||||||
ascii = chunk.decode("ascii")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
raise SpecError("not ascii")
|
|
||||||
else:
|
|
||||||
if PY3:
|
|
||||||
chunk = ascii
|
|
||||||
|
|
||||||
return chunk, data[s.len:]
|
|
||||||
|
|
||||||
def write(s, frame, value):
|
|
||||||
if value is None:
|
|
||||||
return b'\x00' * s.len
|
|
||||||
else:
|
|
||||||
if PY3:
|
|
||||||
value = value.encode("ascii")
|
|
||||||
return (bytes(value) + b'\x00' * s.len)[:s.len]
|
|
||||||
|
|
||||||
def validate(s, frame, value):
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if PY3:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise TypeError("%s has to be str" % s.name)
|
|
||||||
value.encode("ascii")
|
|
||||||
else:
|
|
||||||
if not isinstance(value, bytes):
|
|
||||||
value = value.encode("ascii")
|
|
||||||
|
|
||||||
if len(value) == s.len:
|
|
||||||
return value
|
|
||||||
|
|
||||||
raise ValueError('Invalid StringSpec[%d] data: %r' % (s.len, value))
|
|
||||||
|
|
||||||
|
|
||||||
class BinaryDataSpec(Spec):
|
|
||||||
def read(self, frame, data):
|
|
||||||
return data, b''
|
|
||||||
|
|
||||||
def write(self, frame, value):
|
|
||||||
if value is None:
|
|
||||||
return b""
|
|
||||||
if isinstance(value, bytes):
|
|
||||||
return value
|
|
||||||
value = text_type(value).encode("ascii")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def validate(self, frame, value):
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if isinstance(value, bytes):
|
|
||||||
return value
|
|
||||||
elif PY3:
|
|
||||||
raise TypeError("%s has to be bytes" % self.name)
|
|
||||||
|
|
||||||
value = text_type(value).encode("ascii")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class EncodedTextSpec(Spec):
|
|
||||||
|
|
||||||
_encodings = {
|
|
||||||
Encoding.LATIN1: ('latin1', b'\x00'),
|
|
||||||
Encoding.UTF16: ('utf16', b'\x00\x00'),
|
|
||||||
Encoding.UTF16BE: ('utf_16_be', b'\x00\x00'),
|
|
||||||
Encoding.UTF8: ('utf8', b'\x00'),
|
|
||||||
}
|
|
||||||
|
|
||||||
def read(self, frame, data):
|
|
||||||
enc, term = self._encodings[frame.encoding]
|
|
||||||
try:
|
|
||||||
# allow missing termination
|
|
||||||
return decode_terminated(data, enc, strict=False)
|
|
||||||
except ValueError:
|
|
||||||
# utf-16 termination with missing BOM, or single NULL
|
|
||||||
if not data[:len(term)].strip(b"\x00"):
|
|
||||||
return u"", data[len(term):]
|
|
||||||
|
|
||||||
# utf-16 data with single NULL, see issue 169
|
|
||||||
try:
|
|
||||||
return decode_terminated(data + b"\x00", enc)
|
|
||||||
except ValueError:
|
|
||||||
raise SpecError("Decoding error")
|
|
||||||
|
|
||||||
def write(self, frame, value):
|
|
||||||
enc, term = self._encodings[frame.encoding]
|
|
||||||
return value.encode(enc) + term
|
|
||||||
|
|
||||||
def validate(self, frame, value):
|
|
||||||
return text_type(value)
|
|
||||||
|
|
||||||
|
|
||||||
class MultiSpec(Spec):
|
|
||||||
def __init__(self, name, *specs, **kw):
|
|
||||||
super(MultiSpec, self).__init__(name)
|
|
||||||
self.specs = specs
|
|
||||||
self.sep = kw.get('sep')
|
|
||||||
|
|
||||||
def read(self, frame, data):
|
|
||||||
values = []
|
|
||||||
while data:
|
|
||||||
record = []
|
|
||||||
for spec in self.specs:
|
|
||||||
value, data = spec.read(frame, data)
|
|
||||||
record.append(value)
|
|
||||||
if len(self.specs) != 1:
|
|
||||||
values.append(record)
|
|
||||||
else:
|
|
||||||
values.append(record[0])
|
|
||||||
return values, data
|
|
||||||
|
|
||||||
def write(self, frame, value):
|
|
||||||
data = []
|
|
||||||
if len(self.specs) == 1:
|
|
||||||
for v in value:
|
|
||||||
data.append(self.specs[0].write(frame, v))
|
|
||||||
else:
|
|
||||||
for record in value:
|
|
||||||
for v, s in izip(record, self.specs):
|
|
||||||
data.append(s.write(frame, v))
|
|
||||||
return b''.join(data)
|
|
||||||
|
|
||||||
def validate(self, frame, value):
|
|
||||||
if value is None:
|
|
||||||
return []
|
|
||||||
if self.sep and isinstance(value, string_types):
|
|
||||||
value = value.split(self.sep)
|
|
||||||
if isinstance(value, list):
|
|
||||||
if len(self.specs) == 1:
|
|
||||||
return [self.specs[0].validate(frame, v) for v in value]
|
|
||||||
else:
|
|
||||||
return [
|
|
||||||
[s.validate(frame, v) for (v, s) in izip(val, self.specs)]
|
|
||||||
for val in value]
|
|
||||||
raise ValueError('Invalid MultiSpec data: %r' % value)
|
|
||||||
|
|
||||||
def _validate23(self, frame, value, **kwargs):
|
|
||||||
if len(self.specs) != 1:
|
|
||||||
return [[s._validate23(frame, v, **kwargs)
|
|
||||||
for (v, s) in izip(val, self.specs)]
|
|
||||||
for val in value]
|
|
||||||
|
|
||||||
spec = self.specs[0]
|
|
||||||
|
|
||||||
# Merge single text spec multispecs only.
|
|
||||||
# (TimeStampSpec beeing the exception, but it's not a valid v2.3 frame)
|
|
||||||
if not isinstance(spec, EncodedTextSpec) or \
|
|
||||||
isinstance(spec, TimeStampSpec):
|
|
||||||
return value
|
|
||||||
|
|
||||||
value = [spec._validate23(frame, v, **kwargs) for v in value]
|
|
||||||
if kwargs.get("sep") is not None:
|
|
||||||
return [spec.validate(frame, kwargs["sep"].join(value))]
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class EncodedNumericTextSpec(EncodedTextSpec):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class EncodedNumericPartTextSpec(EncodedTextSpec):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Latin1TextSpec(EncodedTextSpec):
|
|
||||||
def read(self, frame, data):
|
|
||||||
if b'\x00' in data:
|
|
||||||
data, ret = data.split(b'\x00', 1)
|
|
||||||
else:
|
|
||||||
ret = b''
|
|
||||||
return data.decode('latin1'), ret
|
|
||||||
|
|
||||||
def write(self, data, value):
|
|
||||||
return value.encode('latin1') + b'\x00'
|
|
||||||
|
|
||||||
def validate(self, frame, value):
|
|
||||||
return text_type(value)
|
|
||||||
|
|
||||||
|
|
||||||
@swap_to_string
|
|
||||||
@total_ordering
|
|
||||||
class ID3TimeStamp(object):
|
|
||||||
"""A time stamp in ID3v2 format.
|
|
||||||
|
|
||||||
This is a restricted form of the ISO 8601 standard; time stamps
|
|
||||||
take the form of:
|
|
||||||
YYYY-MM-DD HH:MM:SS
|
|
||||||
Or some partial form (YYYY-MM-DD HH, YYYY, etc.).
|
|
||||||
|
|
||||||
The 'text' attribute contains the raw text data of the time stamp.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
def __init__(self, text):
|
|
||||||
if isinstance(text, ID3TimeStamp):
|
|
||||||
text = text.text
|
|
||||||
elif not isinstance(text, text_type):
|
|
||||||
if PY3:
|
|
||||||
raise TypeError("not a str")
|
|
||||||
text = text.decode("utf-8")
|
|
||||||
|
|
||||||
self.text = text
|
|
||||||
|
|
||||||
__formats = ['%04d'] + ['%02d'] * 5
|
|
||||||
__seps = ['-', '-', ' ', ':', ':', 'x']
|
|
||||||
|
|
||||||
def get_text(self):
|
|
||||||
parts = [self.year, self.month, self.day,
|
|
||||||
self.hour, self.minute, self.second]
|
|
||||||
pieces = []
|
|
||||||
for i, part in enumerate(parts):
|
|
||||||
if part is None:
|
|
||||||
break
|
|
||||||
pieces.append(self.__formats[i] % part + self.__seps[i])
|
|
||||||
return u''.join(pieces)[:-1]
|
|
||||||
|
|
||||||
def set_text(self, text, splitre=re.compile('[-T:/.]|\s+')):
|
|
||||||
year, month, day, hour, minute, second = \
|
|
||||||
splitre.split(text + ':::::')[:6]
|
|
||||||
for a in 'year month day hour minute second'.split():
|
|
||||||
try:
|
|
||||||
v = int(locals()[a])
|
|
||||||
except ValueError:
|
|
||||||
v = None
|
|
||||||
setattr(self, a, v)
|
|
||||||
|
|
||||||
text = property(get_text, set_text, doc="ID3v2.4 date and time.")
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.text
|
|
||||||
|
|
||||||
def __bytes__(self):
|
|
||||||
return self.text.encode("utf-8")
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return repr(self.text)
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
return self.text == other.text
|
|
||||||
|
|
||||||
def __lt__(self, other):
|
|
||||||
return self.text < other.text
|
|
||||||
|
|
||||||
__hash__ = object.__hash__
|
|
||||||
|
|
||||||
def encode(self, *args):
|
|
||||||
return self.text.encode(*args)
|
|
||||||
|
|
||||||
|
|
||||||
class TimeStampSpec(EncodedTextSpec):
|
|
||||||
def read(self, frame, data):
|
|
||||||
value, data = super(TimeStampSpec, self).read(frame, data)
|
|
||||||
return self.validate(frame, value), data
|
|
||||||
|
|
||||||
def write(self, frame, data):
|
|
||||||
return super(TimeStampSpec, self).write(frame,
|
|
||||||
data.text.replace(' ', 'T'))
|
|
||||||
|
|
||||||
def validate(self, frame, value):
|
|
||||||
try:
|
|
||||||
return ID3TimeStamp(value)
|
|
||||||
except TypeError:
|
|
||||||
raise ValueError("Invalid ID3TimeStamp: %r" % value)
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelSpec(ByteSpec):
|
|
||||||
(OTHER, MASTER, FRONTRIGHT, FRONTLEFT, BACKRIGHT, BACKLEFT, FRONTCENTRE,
|
|
||||||
BACKCENTRE, SUBWOOFER) = xrange(9)
|
|
||||||
|
|
||||||
|
|
||||||
class VolumeAdjustmentSpec(Spec):
|
|
||||||
def read(self, frame, data):
|
|
||||||
value, = unpack('>h', data[0:2])
|
|
||||||
return value / 512.0, data[2:]
|
|
||||||
|
|
||||||
def write(self, frame, value):
|
|
||||||
number = int(round(value * 512))
|
|
||||||
# pack only fails in 2.7, do it manually in 2.6
|
|
||||||
if not -32768 <= number <= 32767:
|
|
||||||
raise SpecError("not in range")
|
|
||||||
return pack('>h', number)
|
|
||||||
|
|
||||||
def validate(self, frame, value):
|
|
||||||
if value is not None:
|
|
||||||
try:
|
|
||||||
self.write(frame, value)
|
|
||||||
except SpecError:
|
|
||||||
raise ValueError("out of range")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class VolumePeakSpec(Spec):
|
|
||||||
def read(self, frame, data):
|
|
||||||
# http://bugs.xmms.org/attachment.cgi?id=113&action=view
|
|
||||||
peak = 0
|
|
||||||
data_array = bytearray(data)
|
|
||||||
bits = data_array[0]
|
|
||||||
vol_bytes = min(4, (bits + 7) >> 3)
|
|
||||||
# not enough frame data
|
|
||||||
if vol_bytes + 1 > len(data):
|
|
||||||
raise SpecError("not enough frame data")
|
|
||||||
shift = ((8 - (bits & 7)) & 7) + (4 - vol_bytes) * 8
|
|
||||||
for i in xrange(1, vol_bytes + 1):
|
|
||||||
peak *= 256
|
|
||||||
peak += data_array[i]
|
|
||||||
peak *= 2 ** shift
|
|
||||||
return (float(peak) / (2 ** 31 - 1)), data[1 + vol_bytes:]
|
|
||||||
|
|
||||||
def write(self, frame, value):
|
|
||||||
number = int(round(value * 32768))
|
|
||||||
# pack only fails in 2.7, do it manually in 2.6
|
|
||||||
if not 0 <= number <= 65535:
|
|
||||||
raise SpecError("not in range")
|
|
||||||
# always write as 16 bits for sanity.
|
|
||||||
return b"\x10" + pack('>H', number)
|
|
||||||
|
|
||||||
def validate(self, frame, value):
|
|
||||||
if value is not None:
|
|
||||||
try:
|
|
||||||
self.write(frame, value)
|
|
||||||
except SpecError:
|
|
||||||
raise ValueError("out of range")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class SynchronizedTextSpec(EncodedTextSpec):
|
|
||||||
def read(self, frame, data):
|
|
||||||
texts = []
|
|
||||||
encoding, term = self._encodings[frame.encoding]
|
|
||||||
while data:
|
|
||||||
try:
|
|
||||||
value, data = decode_terminated(data, encoding)
|
|
||||||
except ValueError:
|
|
||||||
raise SpecError("decoding error")
|
|
||||||
|
|
||||||
if len(data) < 4:
|
|
||||||
raise SpecError("not enough data")
|
|
||||||
time, = struct.unpack(">I", data[:4])
|
|
||||||
|
|
||||||
texts.append((value, time))
|
|
||||||
data = data[4:]
|
|
||||||
return texts, b""
|
|
||||||
|
|
||||||
def write(self, frame, value):
|
|
||||||
data = []
|
|
||||||
encoding, term = self._encodings[frame.encoding]
|
|
||||||
for text, time in value:
|
|
||||||
text = text.encode(encoding) + term
|
|
||||||
data.append(text + struct.pack(">I", time))
|
|
||||||
return b"".join(data)
|
|
||||||
|
|
||||||
def validate(self, frame, value):
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class KeyEventSpec(Spec):
|
|
||||||
def read(self, frame, data):
|
|
||||||
events = []
|
|
||||||
while len(data) >= 5:
|
|
||||||
events.append(struct.unpack(">bI", data[:5]))
|
|
||||||
data = data[5:]
|
|
||||||
return events, data
|
|
||||||
|
|
||||||
def write(self, frame, value):
|
|
||||||
return b"".join(struct.pack(">bI", *event) for event in value)
|
|
||||||
|
|
||||||
def validate(self, frame, value):
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class VolumeAdjustmentsSpec(Spec):
|
|
||||||
# Not to be confused with VolumeAdjustmentSpec.
|
|
||||||
def read(self, frame, data):
|
|
||||||
adjustments = {}
|
|
||||||
while len(data) >= 4:
|
|
||||||
freq, adj = struct.unpack(">Hh", data[:4])
|
|
||||||
data = data[4:]
|
|
||||||
freq /= 2.0
|
|
||||||
adj /= 512.0
|
|
||||||
adjustments[freq] = adj
|
|
||||||
adjustments = sorted(adjustments.items())
|
|
||||||
return adjustments, data
|
|
||||||
|
|
||||||
def write(self, frame, value):
|
|
||||||
value.sort()
|
|
||||||
return b"".join(struct.pack(">Hh", int(freq * 2), int(adj * 512))
|
|
||||||
for (freq, adj) in value)
|
|
||||||
|
|
||||||
def validate(self, frame, value):
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class ASPIIndexSpec(Spec):
|
|
||||||
def read(self, frame, data):
|
|
||||||
if frame.b == 16:
|
|
||||||
format = "H"
|
|
||||||
size = 2
|
|
||||||
elif frame.b == 8:
|
|
||||||
format = "B"
|
|
||||||
size = 1
|
|
||||||
else:
|
|
||||||
raise SpecError("invalid bit count in ASPI (%d)" % frame.b)
|
|
||||||
|
|
||||||
indexes = data[:frame.N * size]
|
|
||||||
data = data[frame.N * size:]
|
|
||||||
try:
|
|
||||||
return list(struct.unpack(">" + format * frame.N, indexes)), data
|
|
||||||
except struct.error as e:
|
|
||||||
raise SpecError(e)
|
|
||||||
|
|
||||||
def write(self, frame, values):
|
|
||||||
if frame.b == 16:
|
|
||||||
format = "H"
|
|
||||||
elif frame.b == 8:
|
|
||||||
format = "B"
|
|
||||||
else:
|
|
||||||
raise SpecError("frame.b must be 8 or 16")
|
|
||||||
try:
|
|
||||||
return struct.pack(">" + format * frame.N, *values)
|
|
||||||
except struct.error as e:
|
|
||||||
raise SpecError(e)
|
|
||||||
|
|
||||||
def validate(self, frame, values):
|
|
||||||
return values
|
|
|
@ -1,167 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2005 Michael Urman
|
|
||||||
# 2013 Christoph Reiter
|
|
||||||
# 2014 Ben Ockmore
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of version 2 of the GNU General Public License as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
from .._compat import long_, integer_types, PY3
|
|
||||||
from .._util import MutagenError
|
|
||||||
|
|
||||||
|
|
||||||
class error(MutagenError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ID3NoHeaderError(error, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ID3UnsupportedVersionError(error, NotImplementedError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ID3EncryptionUnsupportedError(error, NotImplementedError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ID3JunkFrameError(error, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class unsynch(object):
|
|
||||||
@staticmethod
|
|
||||||
def decode(value):
|
|
||||||
fragments = bytearray(value).split(b'\xff')
|
|
||||||
if len(fragments) > 1 and not fragments[-1]:
|
|
||||||
raise ValueError('string ended unsafe')
|
|
||||||
|
|
||||||
for f in fragments[1:]:
|
|
||||||
if (not f) or (f[0] >= 0xE0):
|
|
||||||
raise ValueError('invalid sync-safe string')
|
|
||||||
|
|
||||||
if f[0] == 0x00:
|
|
||||||
del f[0]
|
|
||||||
|
|
||||||
return bytes(bytearray(b'\xff').join(fragments))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def encode(value):
|
|
||||||
fragments = bytearray(value).split(b'\xff')
|
|
||||||
for f in fragments[1:]:
|
|
||||||
if (not f) or (f[0] >= 0xE0) or (f[0] == 0x00):
|
|
||||||
f.insert(0, 0x00)
|
|
||||||
return bytes(bytearray(b'\xff').join(fragments))
|
|
||||||
|
|
||||||
|
|
||||||
class _BitPaddedMixin(object):
|
|
||||||
|
|
||||||
def as_str(self, width=4, minwidth=4):
|
|
||||||
return self.to_str(self, self.bits, self.bigendian, width, minwidth)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def to_str(value, bits=7, bigendian=True, width=4, minwidth=4):
|
|
||||||
mask = (1 << bits) - 1
|
|
||||||
|
|
||||||
if width != -1:
|
|
||||||
index = 0
|
|
||||||
bytes_ = bytearray(width)
|
|
||||||
try:
|
|
||||||
while value:
|
|
||||||
bytes_[index] = value & mask
|
|
||||||
value >>= bits
|
|
||||||
index += 1
|
|
||||||
except IndexError:
|
|
||||||
raise ValueError('Value too wide (>%d bytes)' % width)
|
|
||||||
else:
|
|
||||||
# PCNT and POPM use growing integers
|
|
||||||
# of at least 4 bytes (=minwidth) as counters.
|
|
||||||
bytes_ = bytearray()
|
|
||||||
append = bytes_.append
|
|
||||||
while value:
|
|
||||||
append(value & mask)
|
|
||||||
value >>= bits
|
|
||||||
bytes_ = bytes_.ljust(minwidth, b"\x00")
|
|
||||||
|
|
||||||
if bigendian:
|
|
||||||
bytes_.reverse()
|
|
||||||
return bytes(bytes_)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def has_valid_padding(value, bits=7):
|
|
||||||
"""Whether the padding bits are all zero"""
|
|
||||||
|
|
||||||
assert bits <= 8
|
|
||||||
|
|
||||||
mask = (((1 << (8 - bits)) - 1) << bits)
|
|
||||||
|
|
||||||
if isinstance(value, integer_types):
|
|
||||||
while value:
|
|
||||||
if value & mask:
|
|
||||||
return False
|
|
||||||
value >>= 8
|
|
||||||
elif isinstance(value, bytes):
|
|
||||||
for byte in bytearray(value):
|
|
||||||
if byte & mask:
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
raise TypeError
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class BitPaddedInt(int, _BitPaddedMixin):
|
|
||||||
|
|
||||||
def __new__(cls, value, bits=7, bigendian=True):
|
|
||||||
|
|
||||||
mask = (1 << (bits)) - 1
|
|
||||||
numeric_value = 0
|
|
||||||
shift = 0
|
|
||||||
|
|
||||||
if isinstance(value, integer_types):
|
|
||||||
while value:
|
|
||||||
numeric_value += (value & mask) << shift
|
|
||||||
value >>= 8
|
|
||||||
shift += bits
|
|
||||||
elif isinstance(value, bytes):
|
|
||||||
if bigendian:
|
|
||||||
value = reversed(value)
|
|
||||||
for byte in bytearray(value):
|
|
||||||
numeric_value += (byte & mask) << shift
|
|
||||||
shift += bits
|
|
||||||
else:
|
|
||||||
raise TypeError
|
|
||||||
|
|
||||||
if isinstance(numeric_value, int):
|
|
||||||
self = int.__new__(BitPaddedInt, numeric_value)
|
|
||||||
else:
|
|
||||||
self = long_.__new__(BitPaddedLong, numeric_value)
|
|
||||||
|
|
||||||
self.bits = bits
|
|
||||||
self.bigendian = bigendian
|
|
||||||
return self
|
|
||||||
|
|
||||||
if PY3:
|
|
||||||
BitPaddedLong = BitPaddedInt
|
|
||||||
else:
|
|
||||||
class BitPaddedLong(long_, _BitPaddedMixin):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ID3BadUnsynchData(error, ValueError):
|
|
||||||
"""Deprecated"""
|
|
||||||
|
|
||||||
|
|
||||||
class ID3BadCompressedData(error, ValueError):
|
|
||||||
"""Deprecated"""
|
|
||||||
|
|
||||||
|
|
||||||
class ID3TagError(error, ValueError):
|
|
||||||
"""Deprecated"""
|
|
||||||
|
|
||||||
|
|
||||||
class ID3Warning(error, UserWarning):
|
|
||||||
"""Deprecated"""
|
|
|
@ -1,101 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright 2006 Joe Wreschnig
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""
|
|
||||||
since 1.9: mutagen.m4a is deprecated; use mutagen.mp4 instead.
|
|
||||||
since 1.31: mutagen.m4a will no longer work; any operation that could fail
|
|
||||||
will fail now.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
from mutagen import FileType, Metadata, StreamInfo
|
|
||||||
from ._util import DictProxy, MutagenError
|
|
||||||
|
|
||||||
warnings.warn(
|
|
||||||
"mutagen.m4a is deprecated; use mutagen.mp4 instead.",
|
|
||||||
DeprecationWarning)
|
|
||||||
|
|
||||||
|
|
||||||
class error(IOError, MutagenError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class M4AMetadataError(error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class M4AStreamInfoError(error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class M4AMetadataValueError(ValueError, M4AMetadataError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['M4A', 'Open', 'delete', 'M4ACover']
|
|
||||||
|
|
||||||
|
|
||||||
class M4ACover(bytes):
|
|
||||||
|
|
||||||
FORMAT_JPEG = 0x0D
|
|
||||||
FORMAT_PNG = 0x0E
|
|
||||||
|
|
||||||
def __new__(cls, data, imageformat=None):
|
|
||||||
self = bytes.__new__(cls, data)
|
|
||||||
if imageformat is None:
|
|
||||||
imageformat = M4ACover.FORMAT_JPEG
|
|
||||||
self.imageformat = imageformat
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class M4ATags(DictProxy, Metadata):
|
|
||||||
|
|
||||||
def load(self, atoms, fileobj):
|
|
||||||
raise error("deprecated")
|
|
||||||
|
|
||||||
def save(self, filename):
|
|
||||||
raise error("deprecated")
|
|
||||||
|
|
||||||
def delete(self, filename):
|
|
||||||
raise error("deprecated")
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
return u""
|
|
||||||
|
|
||||||
|
|
||||||
class M4AInfo(StreamInfo):
|
|
||||||
|
|
||||||
bitrate = 0
|
|
||||||
|
|
||||||
def __init__(self, atoms, fileobj):
|
|
||||||
raise error("deprecated")
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
return u""
|
|
||||||
|
|
||||||
|
|
||||||
class M4A(FileType):
|
|
||||||
|
|
||||||
_mimes = ["audio/mp4", "audio/x-m4a", "audio/mpeg4", "audio/aac"]
|
|
||||||
|
|
||||||
def load(self, filename):
|
|
||||||
raise error("deprecated")
|
|
||||||
|
|
||||||
def add_tags(self):
|
|
||||||
self.tags = M4ATags()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score(filename, fileobj, header):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
Open = M4A
|
|
||||||
|
|
||||||
|
|
||||||
def delete(filename):
|
|
||||||
raise error("deprecated")
|
|
|
@ -1,86 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2006 Lukas Lalinsky
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""Monkey's Audio streams with APEv2 tags.
|
|
||||||
|
|
||||||
Monkey's Audio is a very efficient lossless audio compressor developed
|
|
||||||
by Matt Ashland.
|
|
||||||
|
|
||||||
For more information, see http://www.monkeysaudio.com/.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__all__ = ["MonkeysAudio", "Open", "delete"]
|
|
||||||
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from ._compat import endswith
|
|
||||||
from mutagen import StreamInfo
|
|
||||||
from mutagen.apev2 import APEv2File, error, delete
|
|
||||||
from mutagen._util import cdata
|
|
||||||
|
|
||||||
|
|
||||||
class MonkeysAudioHeaderError(error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class MonkeysAudioInfo(StreamInfo):
|
|
||||||
"""Monkey's Audio stream information.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
* channels -- number of audio channels
|
|
||||||
* length -- file length in seconds, as a float
|
|
||||||
* sample_rate -- audio sampling rate in Hz
|
|
||||||
* bits_per_sample -- bits per sample
|
|
||||||
* version -- Monkey's Audio stream version, as a float (eg: 3.99)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
header = fileobj.read(76)
|
|
||||||
if len(header) != 76 or not header.startswith(b"MAC "):
|
|
||||||
raise MonkeysAudioHeaderError("not a Monkey's Audio file")
|
|
||||||
self.version = cdata.ushort_le(header[4:6])
|
|
||||||
if self.version >= 3980:
|
|
||||||
(blocks_per_frame, final_frame_blocks, total_frames,
|
|
||||||
self.bits_per_sample, self.channels,
|
|
||||||
self.sample_rate) = struct.unpack("<IIIHHI", header[56:76])
|
|
||||||
else:
|
|
||||||
compression_level = cdata.ushort_le(header[6:8])
|
|
||||||
self.channels, self.sample_rate = struct.unpack(
|
|
||||||
"<HI", header[10:16])
|
|
||||||
total_frames, final_frame_blocks = struct.unpack(
|
|
||||||
"<II", header[24:32])
|
|
||||||
if self.version >= 3950:
|
|
||||||
blocks_per_frame = 73728 * 4
|
|
||||||
elif self.version >= 3900 or (self.version >= 3800 and
|
|
||||||
compression_level == 4):
|
|
||||||
blocks_per_frame = 73728
|
|
||||||
else:
|
|
||||||
blocks_per_frame = 9216
|
|
||||||
self.version /= 1000.0
|
|
||||||
self.length = 0.0
|
|
||||||
if (self.sample_rate != 0) and (total_frames > 0):
|
|
||||||
total_blocks = ((total_frames - 1) * blocks_per_frame +
|
|
||||||
final_frame_blocks)
|
|
||||||
self.length = float(total_blocks) / self.sample_rate
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
return u"Monkey's Audio %.2f, %.2f seconds, %d Hz" % (
|
|
||||||
self.version, self.length, self.sample_rate)
|
|
||||||
|
|
||||||
|
|
||||||
class MonkeysAudio(APEv2File):
|
|
||||||
_Info = MonkeysAudioInfo
|
|
||||||
_mimes = ["audio/ape", "audio/x-ape"]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score(filename, fileobj, header):
|
|
||||||
return header.startswith(b"MAC ") + endswith(filename.lower(), ".ape")
|
|
||||||
|
|
||||||
|
|
||||||
Open = MonkeysAudio
|
|
|
@ -1,362 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2006 Joe Wreschnig
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of version 2 of the GNU General Public License as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""MPEG audio stream information and tags."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from ._compat import endswith, xrange
|
|
||||||
from ._mp3util import XingHeader, XingHeaderError, VBRIHeader, VBRIHeaderError
|
|
||||||
from mutagen import StreamInfo
|
|
||||||
from mutagen._util import MutagenError, enum
|
|
||||||
from mutagen.id3 import ID3FileType, BitPaddedInt, delete
|
|
||||||
|
|
||||||
__all__ = ["MP3", "Open", "delete", "MP3"]
|
|
||||||
|
|
||||||
|
|
||||||
class error(RuntimeError, MutagenError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class HeaderNotFoundError(error, IOError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidMPEGHeader(error, IOError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@enum
|
|
||||||
class BitrateMode(object):
|
|
||||||
|
|
||||||
UNKNOWN = 0
|
|
||||||
"""Probably a CBR file, but not sure"""
|
|
||||||
|
|
||||||
CBR = 1
|
|
||||||
"""Constant Bitrate"""
|
|
||||||
|
|
||||||
VBR = 2
|
|
||||||
"""Variable Bitrate"""
|
|
||||||
|
|
||||||
ABR = 3
|
|
||||||
"""Average Bitrate (a variant of VBR)"""
|
|
||||||
|
|
||||||
|
|
||||||
def _guess_xing_bitrate_mode(xing):
|
|
||||||
|
|
||||||
if xing.lame_header:
|
|
||||||
lame = xing.lame_header
|
|
||||||
if lame.vbr_method in (1, 8):
|
|
||||||
return BitrateMode.CBR
|
|
||||||
elif lame.vbr_method in (2, 9):
|
|
||||||
return BitrateMode.ABR
|
|
||||||
elif lame.vbr_method in (3, 4, 5, 6):
|
|
||||||
return BitrateMode.VBR
|
|
||||||
# everything else undefined, continue guessing
|
|
||||||
|
|
||||||
# info tags get only written by lame for cbr files
|
|
||||||
if xing.is_info:
|
|
||||||
return BitrateMode.CBR
|
|
||||||
|
|
||||||
# older lame and non-lame with some variant of vbr
|
|
||||||
if xing.vbr_scale != -1 or xing.lame_version:
|
|
||||||
return BitrateMode.VBR
|
|
||||||
|
|
||||||
return BitrateMode.UNKNOWN
|
|
||||||
|
|
||||||
|
|
||||||
# Mode values.
|
|
||||||
STEREO, JOINTSTEREO, DUALCHANNEL, MONO = xrange(4)
|
|
||||||
|
|
||||||
|
|
||||||
class MPEGInfo(StreamInfo):
|
|
||||||
"""MPEG audio stream information
|
|
||||||
|
|
||||||
Parse information about an MPEG audio file. This also reads the
|
|
||||||
Xing VBR header format.
|
|
||||||
|
|
||||||
This code was implemented based on the format documentation at
|
|
||||||
http://mpgedit.org/mpgedit/mpeg_format/mpeghdr.htm.
|
|
||||||
|
|
||||||
Useful attributes:
|
|
||||||
|
|
||||||
* length -- audio length, in seconds
|
|
||||||
* channels -- number of audio channels
|
|
||||||
* bitrate -- audio bitrate, in bits per second
|
|
||||||
* sketchy -- if true, the file may not be valid MPEG audio
|
|
||||||
* encoder_info -- a string containing encoder name and possibly version.
|
|
||||||
In case a lame tag is present this will start with
|
|
||||||
``"LAME "``, if unknown it is empty, otherwise the
|
|
||||||
text format is undefined.
|
|
||||||
* bitrate_mode -- a :class:`BitrateMode`
|
|
||||||
|
|
||||||
* track_gain -- replaygain track gain (89db) or None
|
|
||||||
* track_peak -- replaygain track peak or None
|
|
||||||
* album_gain -- replaygain album gain (89db) or None
|
|
||||||
|
|
||||||
Useless attributes:
|
|
||||||
|
|
||||||
* version -- MPEG version (1, 2, 2.5)
|
|
||||||
* layer -- 1, 2, or 3
|
|
||||||
* mode -- One of STEREO, JOINTSTEREO, DUALCHANNEL, or MONO (0-3)
|
|
||||||
* protected -- whether or not the file is "protected"
|
|
||||||
* padding -- whether or not audio frames are padded
|
|
||||||
* sample_rate -- audio sample rate, in Hz
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Map (version, layer) tuples to bitrates.
|
|
||||||
__BITRATE = {
|
|
||||||
(1, 1): [0, 32, 64, 96, 128, 160, 192, 224,
|
|
||||||
256, 288, 320, 352, 384, 416, 448],
|
|
||||||
(1, 2): [0, 32, 48, 56, 64, 80, 96, 112, 128,
|
|
||||||
160, 192, 224, 256, 320, 384],
|
|
||||||
(1, 3): [0, 32, 40, 48, 56, 64, 80, 96, 112,
|
|
||||||
128, 160, 192, 224, 256, 320],
|
|
||||||
(2, 1): [0, 32, 48, 56, 64, 80, 96, 112, 128,
|
|
||||||
144, 160, 176, 192, 224, 256],
|
|
||||||
(2, 2): [0, 8, 16, 24, 32, 40, 48, 56, 64,
|
|
||||||
80, 96, 112, 128, 144, 160],
|
|
||||||
}
|
|
||||||
|
|
||||||
__BITRATE[(2, 3)] = __BITRATE[(2, 2)]
|
|
||||||
for i in xrange(1, 4):
|
|
||||||
__BITRATE[(2.5, i)] = __BITRATE[(2, i)]
|
|
||||||
|
|
||||||
# Map version to sample rates.
|
|
||||||
__RATES = {
|
|
||||||
1: [44100, 48000, 32000],
|
|
||||||
2: [22050, 24000, 16000],
|
|
||||||
2.5: [11025, 12000, 8000]
|
|
||||||
}
|
|
||||||
|
|
||||||
sketchy = False
|
|
||||||
encoder_info = u""
|
|
||||||
bitrate_mode = BitrateMode.UNKNOWN
|
|
||||||
track_gain = track_peak = album_gain = album_peak = None
|
|
||||||
|
|
||||||
def __init__(self, fileobj, offset=None):
|
|
||||||
"""Parse MPEG stream information from a file-like object.
|
|
||||||
|
|
||||||
If an offset argument is given, it is used to start looking
|
|
||||||
for stream information and Xing headers; otherwise, ID3v2 tags
|
|
||||||
will be skipped automatically. A correct offset can make
|
|
||||||
loading files significantly faster.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
size = os.path.getsize(fileobj.name)
|
|
||||||
except (IOError, OSError, AttributeError):
|
|
||||||
fileobj.seek(0, 2)
|
|
||||||
size = fileobj.tell()
|
|
||||||
|
|
||||||
# If we don't get an offset, try to skip an ID3v2 tag.
|
|
||||||
if offset is None:
|
|
||||||
fileobj.seek(0, 0)
|
|
||||||
idata = fileobj.read(10)
|
|
||||||
try:
|
|
||||||
id3, insize = struct.unpack('>3sxxx4s', idata)
|
|
||||||
except struct.error:
|
|
||||||
id3, insize = b'', 0
|
|
||||||
insize = BitPaddedInt(insize)
|
|
||||||
if id3 == b'ID3' and insize > 0:
|
|
||||||
offset = insize + 10
|
|
||||||
else:
|
|
||||||
offset = 0
|
|
||||||
|
|
||||||
# Try to find two valid headers (meaning, very likely MPEG data)
|
|
||||||
# at the given offset, 30% through the file, 60% through the file,
|
|
||||||
# and 90% through the file.
|
|
||||||
for i in [offset, 0.3 * size, 0.6 * size, 0.9 * size]:
|
|
||||||
try:
|
|
||||||
self.__try(fileobj, int(i), size - offset)
|
|
||||||
except error:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
# If we can't find any two consecutive frames, try to find just
|
|
||||||
# one frame back at the original offset given.
|
|
||||||
else:
|
|
||||||
self.__try(fileobj, offset, size - offset, False)
|
|
||||||
self.sketchy = True
|
|
||||||
|
|
||||||
def __try(self, fileobj, offset, real_size, check_second=True):
|
|
||||||
# This is going to be one really long function; bear with it,
|
|
||||||
# because there's not really a sane point to cut it up.
|
|
||||||
fileobj.seek(offset, 0)
|
|
||||||
|
|
||||||
# We "know" we have an MPEG file if we find two frames that look like
|
|
||||||
# valid MPEG data. If we can't find them in 32k of reads, something
|
|
||||||
# is horribly wrong (the longest frame can only be about 4k). This
|
|
||||||
# is assuming the offset didn't lie.
|
|
||||||
data = fileobj.read(32768)
|
|
||||||
|
|
||||||
frame_1 = data.find(b"\xff")
|
|
||||||
while 0 <= frame_1 <= (len(data) - 4):
|
|
||||||
frame_data = struct.unpack(">I", data[frame_1:frame_1 + 4])[0]
|
|
||||||
if ((frame_data >> 16) & 0xE0) != 0xE0:
|
|
||||||
frame_1 = data.find(b"\xff", frame_1 + 2)
|
|
||||||
else:
|
|
||||||
version = (frame_data >> 19) & 0x3
|
|
||||||
layer = (frame_data >> 17) & 0x3
|
|
||||||
protection = (frame_data >> 16) & 0x1
|
|
||||||
bitrate = (frame_data >> 12) & 0xF
|
|
||||||
sample_rate = (frame_data >> 10) & 0x3
|
|
||||||
padding = (frame_data >> 9) & 0x1
|
|
||||||
# private = (frame_data >> 8) & 0x1
|
|
||||||
self.mode = (frame_data >> 6) & 0x3
|
|
||||||
# mode_extension = (frame_data >> 4) & 0x3
|
|
||||||
# copyright = (frame_data >> 3) & 0x1
|
|
||||||
# original = (frame_data >> 2) & 0x1
|
|
||||||
# emphasis = (frame_data >> 0) & 0x3
|
|
||||||
if (version == 1 or layer == 0 or sample_rate == 0x3 or
|
|
||||||
bitrate == 0 or bitrate == 0xF):
|
|
||||||
frame_1 = data.find(b"\xff", frame_1 + 2)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise HeaderNotFoundError("can't sync to an MPEG frame")
|
|
||||||
|
|
||||||
self.channels = 1 if self.mode == MONO else 2
|
|
||||||
|
|
||||||
# There is a serious problem here, which is that many flags
|
|
||||||
# in an MPEG header are backwards.
|
|
||||||
self.version = [2.5, None, 2, 1][version]
|
|
||||||
self.layer = 4 - layer
|
|
||||||
self.protected = not protection
|
|
||||||
self.padding = bool(padding)
|
|
||||||
|
|
||||||
self.bitrate = self.__BITRATE[(self.version, self.layer)][bitrate]
|
|
||||||
self.bitrate *= 1000
|
|
||||||
self.sample_rate = self.__RATES[self.version][sample_rate]
|
|
||||||
|
|
||||||
if self.layer == 1:
|
|
||||||
frame_length = (
|
|
||||||
(12 * self.bitrate // self.sample_rate) + padding) * 4
|
|
||||||
frame_size = 384
|
|
||||||
elif self.version >= 2 and self.layer == 3:
|
|
||||||
frame_length = (72 * self.bitrate // self.sample_rate) + padding
|
|
||||||
frame_size = 576
|
|
||||||
else:
|
|
||||||
frame_length = (144 * self.bitrate // self.sample_rate) + padding
|
|
||||||
frame_size = 1152
|
|
||||||
|
|
||||||
if check_second:
|
|
||||||
possible = int(frame_1 + frame_length)
|
|
||||||
if possible > len(data) + 4:
|
|
||||||
raise HeaderNotFoundError("can't sync to second MPEG frame")
|
|
||||||
try:
|
|
||||||
frame_data = struct.unpack(
|
|
||||||
">H", data[possible:possible + 2])[0]
|
|
||||||
except struct.error:
|
|
||||||
raise HeaderNotFoundError("can't sync to second MPEG frame")
|
|
||||||
if (frame_data & 0xFFE0) != 0xFFE0:
|
|
||||||
raise HeaderNotFoundError("can't sync to second MPEG frame")
|
|
||||||
|
|
||||||
self.length = 8 * real_size / float(self.bitrate)
|
|
||||||
|
|
||||||
# Try to find/parse the Xing header, which trumps the above length
|
|
||||||
# and bitrate calculation.
|
|
||||||
|
|
||||||
if self.layer != 3:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Xing
|
|
||||||
xing_offset = XingHeader.get_offset(self)
|
|
||||||
fileobj.seek(offset + frame_1 + xing_offset, 0)
|
|
||||||
try:
|
|
||||||
xing = XingHeader(fileobj)
|
|
||||||
except XingHeaderError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
lame = xing.lame_header
|
|
||||||
self.sketchy = False
|
|
||||||
self.bitrate_mode = _guess_xing_bitrate_mode(xing)
|
|
||||||
if xing.frames != -1:
|
|
||||||
samples = frame_size * xing.frames
|
|
||||||
if lame is not None:
|
|
||||||
samples -= lame.encoder_delay_start
|
|
||||||
samples -= lame.encoder_padding_end
|
|
||||||
self.length = float(samples) / self.sample_rate
|
|
||||||
if xing.bytes != -1 and self.length:
|
|
||||||
self.bitrate = int((xing.bytes * 8) / self.length)
|
|
||||||
if xing.lame_version:
|
|
||||||
self.encoder_info = u"LAME %s" % xing.lame_version
|
|
||||||
if lame is not None:
|
|
||||||
self.track_gain = lame.track_gain_adjustment
|
|
||||||
self.track_peak = lame.track_peak
|
|
||||||
self.album_gain = lame.album_gain_adjustment
|
|
||||||
return
|
|
||||||
|
|
||||||
# VBRI
|
|
||||||
vbri_offset = VBRIHeader.get_offset(self)
|
|
||||||
fileobj.seek(offset + frame_1 + vbri_offset, 0)
|
|
||||||
try:
|
|
||||||
vbri = VBRIHeader(fileobj)
|
|
||||||
except VBRIHeaderError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
self.bitrate_mode = BitrateMode.VBR
|
|
||||||
self.encoder_info = u"FhG"
|
|
||||||
self.sketchy = False
|
|
||||||
self.length = float(frame_size * vbri.frames) / self.sample_rate
|
|
||||||
if self.length:
|
|
||||||
self.bitrate = int((vbri.bytes * 8) / self.length)
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
info = str(self.bitrate_mode).split(".", 1)[-1]
|
|
||||||
if self.bitrate_mode == BitrateMode.UNKNOWN:
|
|
||||||
info = u"CBR?"
|
|
||||||
if self.encoder_info:
|
|
||||||
info += ", %s" % self.encoder_info
|
|
||||||
s = u"MPEG %s layer %d, %d bps (%s), %s Hz, %d chn, %.2f seconds" % (
|
|
||||||
self.version, self.layer, self.bitrate, info,
|
|
||||||
self.sample_rate, self.channels, self.length)
|
|
||||||
if self.sketchy:
|
|
||||||
s += u" (sketchy)"
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
class MP3(ID3FileType):
|
|
||||||
"""An MPEG audio (usually MPEG-1 Layer 3) file.
|
|
||||||
|
|
||||||
:ivar info: :class:`MPEGInfo`
|
|
||||||
:ivar tags: :class:`ID3 <mutagen.id3.ID3>`
|
|
||||||
"""
|
|
||||||
|
|
||||||
_Info = MPEGInfo
|
|
||||||
|
|
||||||
_mimes = ["audio/mpeg", "audio/mpg", "audio/x-mpeg"]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def mime(self):
|
|
||||||
l = self.info.layer
|
|
||||||
return ["audio/mp%d" % l, "audio/x-mp%d" % l] + super(MP3, self).mime
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score(filename, fileobj, header_data):
|
|
||||||
filename = filename.lower()
|
|
||||||
|
|
||||||
return (header_data.startswith(b"ID3") * 2 +
|
|
||||||
endswith(filename, b".mp3") +
|
|
||||||
endswith(filename, b".mp2") + endswith(filename, b".mpg") +
|
|
||||||
endswith(filename, b".mpeg"))
|
|
||||||
|
|
||||||
|
|
||||||
Open = MP3
|
|
||||||
|
|
||||||
|
|
||||||
class EasyMP3(MP3):
|
|
||||||
"""Like MP3, but uses EasyID3 for tags.
|
|
||||||
|
|
||||||
:ivar info: :class:`MPEGInfo`
|
|
||||||
:ivar tags: :class:`EasyID3 <mutagen.easyid3.EasyID3>`
|
|
||||||
"""
|
|
||||||
|
|
||||||
from mutagen.easyid3 import EasyID3 as ID3
|
|
||||||
ID3 = ID3
|
|
File diff suppressed because it is too large
Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -1,542 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright (C) 2014 Christoph Reiter
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
from mutagen._compat import cBytesIO, xrange
|
|
||||||
from mutagen.aac import ProgramConfigElement
|
|
||||||
from mutagen._util import BitReader, BitReaderError, cdata
|
|
||||||
from mutagen._compat import text_type
|
|
||||||
from ._util import parse_full_atom
|
|
||||||
from ._atom import Atom, AtomError
|
|
||||||
|
|
||||||
|
|
||||||
class ASEntryError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class AudioSampleEntry(object):
|
|
||||||
"""Parses an AudioSampleEntry atom.
|
|
||||||
|
|
||||||
Private API.
|
|
||||||
|
|
||||||
Attrs:
|
|
||||||
channels (int): number of channels
|
|
||||||
sample_size (int): sample size in bits
|
|
||||||
sample_rate (int): sample rate in Hz
|
|
||||||
bitrate (int): bits per second (0 means unknown)
|
|
||||||
codec (string):
|
|
||||||
audio codec, either 'mp4a[.*][.*]' (rfc6381) or 'alac'
|
|
||||||
codec_description (string): descriptive codec name e.g. "AAC LC+SBR"
|
|
||||||
|
|
||||||
Can raise ASEntryError.
|
|
||||||
"""
|
|
||||||
|
|
||||||
channels = 0
|
|
||||||
sample_size = 0
|
|
||||||
sample_rate = 0
|
|
||||||
bitrate = 0
|
|
||||||
codec = None
|
|
||||||
codec_description = None
|
|
||||||
|
|
||||||
def __init__(self, atom, fileobj):
|
|
||||||
ok, data = atom.read(fileobj)
|
|
||||||
if not ok:
|
|
||||||
raise ASEntryError("too short %r atom" % atom.name)
|
|
||||||
|
|
||||||
fileobj = cBytesIO(data)
|
|
||||||
r = BitReader(fileobj)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# SampleEntry
|
|
||||||
r.skip(6 * 8) # reserved
|
|
||||||
r.skip(2 * 8) # data_ref_index
|
|
||||||
|
|
||||||
# AudioSampleEntry
|
|
||||||
r.skip(8 * 8) # reserved
|
|
||||||
self.channels = r.bits(16)
|
|
||||||
self.sample_size = r.bits(16)
|
|
||||||
r.skip(2 * 8) # pre_defined
|
|
||||||
r.skip(2 * 8) # reserved
|
|
||||||
self.sample_rate = r.bits(32) >> 16
|
|
||||||
except BitReaderError as e:
|
|
||||||
raise ASEntryError(e)
|
|
||||||
|
|
||||||
assert r.is_aligned()
|
|
||||||
|
|
||||||
try:
|
|
||||||
extra = Atom(fileobj)
|
|
||||||
except AtomError as e:
|
|
||||||
raise ASEntryError(e)
|
|
||||||
|
|
||||||
self.codec = atom.name.decode("latin-1")
|
|
||||||
self.codec_description = None
|
|
||||||
|
|
||||||
if atom.name == b"mp4a" and extra.name == b"esds":
|
|
||||||
self._parse_esds(extra, fileobj)
|
|
||||||
elif atom.name == b"alac" and extra.name == b"alac":
|
|
||||||
self._parse_alac(extra, fileobj)
|
|
||||||
elif atom.name == b"ac-3" and extra.name == b"dac3":
|
|
||||||
self._parse_dac3(extra, fileobj)
|
|
||||||
|
|
||||||
if self.codec_description is None:
|
|
||||||
self.codec_description = self.codec.upper()
|
|
||||||
|
|
||||||
def _parse_dac3(self, atom, fileobj):
|
|
||||||
# ETSI TS 102 366
|
|
||||||
|
|
||||||
assert atom.name == b"dac3"
|
|
||||||
|
|
||||||
ok, data = atom.read(fileobj)
|
|
||||||
if not ok:
|
|
||||||
raise ASEntryError("truncated %s atom" % atom.name)
|
|
||||||
fileobj = cBytesIO(data)
|
|
||||||
r = BitReader(fileobj)
|
|
||||||
|
|
||||||
# sample_rate in AudioSampleEntry covers values in
|
|
||||||
# fscod2 and not just fscod, so ignore fscod here.
|
|
||||||
try:
|
|
||||||
r.skip(2 + 5 + 3) # fscod, bsid, bsmod
|
|
||||||
acmod = r.bits(3)
|
|
||||||
lfeon = r.bits(1)
|
|
||||||
bit_rate_code = r.bits(5)
|
|
||||||
r.skip(5) # reserved
|
|
||||||
except BitReaderError as e:
|
|
||||||
raise ASEntryError(e)
|
|
||||||
|
|
||||||
self.channels = [2, 1, 2, 3, 3, 4, 4, 5][acmod] + lfeon
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.bitrate = [
|
|
||||||
32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192,
|
|
||||||
224, 256, 320, 384, 448, 512, 576, 640][bit_rate_code] * 1000
|
|
||||||
except IndexError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _parse_alac(self, atom, fileobj):
|
|
||||||
# https://alac.macosforge.org/trac/browser/trunk/
|
|
||||||
# ALACMagicCookieDescription.txt
|
|
||||||
|
|
||||||
assert atom.name == b"alac"
|
|
||||||
|
|
||||||
ok, data = atom.read(fileobj)
|
|
||||||
if not ok:
|
|
||||||
raise ASEntryError("truncated %s atom" % atom.name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
version, flags, data = parse_full_atom(data)
|
|
||||||
except ValueError as e:
|
|
||||||
raise ASEntryError(e)
|
|
||||||
|
|
||||||
if version != 0:
|
|
||||||
raise ASEntryError("Unsupported version %d" % version)
|
|
||||||
|
|
||||||
fileobj = cBytesIO(data)
|
|
||||||
r = BitReader(fileobj)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# for some files the AudioSampleEntry values default to 44100/2chan
|
|
||||||
# and the real info is in the alac cookie, so prefer it
|
|
||||||
r.skip(32) # frameLength
|
|
||||||
compatibleVersion = r.bits(8)
|
|
||||||
if compatibleVersion != 0:
|
|
||||||
return
|
|
||||||
self.sample_size = r.bits(8)
|
|
||||||
r.skip(8 + 8 + 8)
|
|
||||||
self.channels = r.bits(8)
|
|
||||||
r.skip(16 + 32)
|
|
||||||
self.bitrate = r.bits(32)
|
|
||||||
self.sample_rate = r.bits(32)
|
|
||||||
except BitReaderError as e:
|
|
||||||
raise ASEntryError(e)
|
|
||||||
|
|
||||||
def _parse_esds(self, esds, fileobj):
|
|
||||||
assert esds.name == b"esds"
|
|
||||||
|
|
||||||
ok, data = esds.read(fileobj)
|
|
||||||
if not ok:
|
|
||||||
raise ASEntryError("truncated %s atom" % esds.name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
version, flags, data = parse_full_atom(data)
|
|
||||||
except ValueError as e:
|
|
||||||
raise ASEntryError(e)
|
|
||||||
|
|
||||||
if version != 0:
|
|
||||||
raise ASEntryError("Unsupported version %d" % version)
|
|
||||||
|
|
||||||
fileobj = cBytesIO(data)
|
|
||||||
r = BitReader(fileobj)
|
|
||||||
|
|
||||||
try:
|
|
||||||
tag = r.bits(8)
|
|
||||||
if tag != ES_Descriptor.TAG:
|
|
||||||
raise ASEntryError("unexpected descriptor: %d" % tag)
|
|
||||||
assert r.is_aligned()
|
|
||||||
except BitReaderError as e:
|
|
||||||
raise ASEntryError(e)
|
|
||||||
|
|
||||||
try:
|
|
||||||
decSpecificInfo = ES_Descriptor.parse(fileobj)
|
|
||||||
except DescriptorError as e:
|
|
||||||
raise ASEntryError(e)
|
|
||||||
dec_conf_desc = decSpecificInfo.decConfigDescr
|
|
||||||
|
|
||||||
self.bitrate = dec_conf_desc.avgBitrate
|
|
||||||
self.codec += dec_conf_desc.codec_param
|
|
||||||
self.codec_description = dec_conf_desc.codec_desc
|
|
||||||
|
|
||||||
decSpecificInfo = dec_conf_desc.decSpecificInfo
|
|
||||||
if decSpecificInfo is not None:
|
|
||||||
if decSpecificInfo.channels != 0:
|
|
||||||
self.channels = decSpecificInfo.channels
|
|
||||||
|
|
||||||
if decSpecificInfo.sample_rate != 0:
|
|
||||||
self.sample_rate = decSpecificInfo.sample_rate
|
|
||||||
|
|
||||||
|
|
||||||
class DescriptorError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class BaseDescriptor(object):
|
|
||||||
|
|
||||||
TAG = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _parse_desc_length_file(cls, fileobj):
|
|
||||||
"""May raise ValueError"""
|
|
||||||
|
|
||||||
value = 0
|
|
||||||
for i in xrange(4):
|
|
||||||
try:
|
|
||||||
b = cdata.uint8(fileobj.read(1))
|
|
||||||
except cdata.error as e:
|
|
||||||
raise ValueError(e)
|
|
||||||
value = (value << 7) | (b & 0x7f)
|
|
||||||
if not b >> 7:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise ValueError("invalid descriptor length")
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def parse(cls, fileobj):
|
|
||||||
"""Returns a parsed instance of the called type.
|
|
||||||
The file position is right after the descriptor after this returns.
|
|
||||||
|
|
||||||
Raises DescriptorError
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
length = cls._parse_desc_length_file(fileobj)
|
|
||||||
except ValueError as e:
|
|
||||||
raise DescriptorError(e)
|
|
||||||
pos = fileobj.tell()
|
|
||||||
instance = cls(fileobj, length)
|
|
||||||
left = length - (fileobj.tell() - pos)
|
|
||||||
if left < 0:
|
|
||||||
raise DescriptorError("descriptor parsing read too much data")
|
|
||||||
fileobj.seek(left, 1)
|
|
||||||
return instance
|
|
||||||
|
|
||||||
|
|
||||||
class ES_Descriptor(BaseDescriptor):
|
|
||||||
|
|
||||||
TAG = 0x3
|
|
||||||
|
|
||||||
def __init__(self, fileobj, length):
|
|
||||||
"""Raises DescriptorError"""
|
|
||||||
|
|
||||||
r = BitReader(fileobj)
|
|
||||||
try:
|
|
||||||
self.ES_ID = r.bits(16)
|
|
||||||
self.streamDependenceFlag = r.bits(1)
|
|
||||||
self.URL_Flag = r.bits(1)
|
|
||||||
self.OCRstreamFlag = r.bits(1)
|
|
||||||
self.streamPriority = r.bits(5)
|
|
||||||
if self.streamDependenceFlag:
|
|
||||||
self.dependsOn_ES_ID = r.bits(16)
|
|
||||||
if self.URL_Flag:
|
|
||||||
URLlength = r.bits(8)
|
|
||||||
self.URLstring = r.bytes(URLlength)
|
|
||||||
if self.OCRstreamFlag:
|
|
||||||
self.OCR_ES_Id = r.bits(16)
|
|
||||||
|
|
||||||
tag = r.bits(8)
|
|
||||||
except BitReaderError as e:
|
|
||||||
raise DescriptorError(e)
|
|
||||||
|
|
||||||
if tag != DecoderConfigDescriptor.TAG:
|
|
||||||
raise DescriptorError("unexpected DecoderConfigDescrTag %d" % tag)
|
|
||||||
|
|
||||||
assert r.is_aligned()
|
|
||||||
self.decConfigDescr = DecoderConfigDescriptor.parse(fileobj)
|
|
||||||
|
|
||||||
|
|
||||||
class DecoderConfigDescriptor(BaseDescriptor):
|
|
||||||
|
|
||||||
TAG = 0x4
|
|
||||||
|
|
||||||
decSpecificInfo = None
|
|
||||||
"""A DecoderSpecificInfo, optional"""
|
|
||||||
|
|
||||||
def __init__(self, fileobj, length):
|
|
||||||
"""Raises DescriptorError"""
|
|
||||||
|
|
||||||
r = BitReader(fileobj)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.objectTypeIndication = r.bits(8)
|
|
||||||
self.streamType = r.bits(6)
|
|
||||||
self.upStream = r.bits(1)
|
|
||||||
self.reserved = r.bits(1)
|
|
||||||
self.bufferSizeDB = r.bits(24)
|
|
||||||
self.maxBitrate = r.bits(32)
|
|
||||||
self.avgBitrate = r.bits(32)
|
|
||||||
|
|
||||||
if (self.objectTypeIndication, self.streamType) != (0x40, 0x5):
|
|
||||||
return
|
|
||||||
|
|
||||||
# all from here is optional
|
|
||||||
if length * 8 == r.get_position():
|
|
||||||
return
|
|
||||||
|
|
||||||
tag = r.bits(8)
|
|
||||||
except BitReaderError as e:
|
|
||||||
raise DescriptorError(e)
|
|
||||||
|
|
||||||
if tag == DecoderSpecificInfo.TAG:
|
|
||||||
assert r.is_aligned()
|
|
||||||
self.decSpecificInfo = DecoderSpecificInfo.parse(fileobj)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def codec_param(self):
|
|
||||||
"""string"""
|
|
||||||
|
|
||||||
param = u".%X" % self.objectTypeIndication
|
|
||||||
info = self.decSpecificInfo
|
|
||||||
if info is not None:
|
|
||||||
param += u".%d" % info.audioObjectType
|
|
||||||
return param
|
|
||||||
|
|
||||||
@property
|
|
||||||
def codec_desc(self):
|
|
||||||
"""string or None"""
|
|
||||||
|
|
||||||
info = self.decSpecificInfo
|
|
||||||
desc = None
|
|
||||||
if info is not None:
|
|
||||||
desc = info.description
|
|
||||||
return desc
|
|
||||||
|
|
||||||
|
|
||||||
class DecoderSpecificInfo(BaseDescriptor):
|
|
||||||
|
|
||||||
TAG = 0x5
|
|
||||||
|
|
||||||
_TYPE_NAMES = [
|
|
||||||
None, "AAC MAIN", "AAC LC", "AAC SSR", "AAC LTP", "SBR",
|
|
||||||
"AAC scalable", "TwinVQ", "CELP", "HVXC", None, None, "TTSI",
|
|
||||||
"Main synthetic", "Wavetable synthesis", "General MIDI",
|
|
||||||
"Algorithmic Synthesis and Audio FX", "ER AAC LC", None, "ER AAC LTP",
|
|
||||||
"ER AAC scalable", "ER Twin VQ", "ER BSAC", "ER AAC LD", "ER CELP",
|
|
||||||
"ER HVXC", "ER HILN", "ER Parametric", "SSC", "PS", "MPEG Surround",
|
|
||||||
None, "Layer-1", "Layer-2", "Layer-3", "DST", "ALS", "SLS",
|
|
||||||
"SLS non-core", "ER AAC ELD", "SMR Simple", "SMR Main", "USAC",
|
|
||||||
"SAOC", "LD MPEG Surround", "USAC"
|
|
||||||
]
|
|
||||||
|
|
||||||
_FREQS = [
|
|
||||||
96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000,
|
|
||||||
12000, 11025, 8000, 7350,
|
|
||||||
]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self):
|
|
||||||
"""string or None if unknown"""
|
|
||||||
|
|
||||||
name = None
|
|
||||||
try:
|
|
||||||
name = self._TYPE_NAMES[self.audioObjectType]
|
|
||||||
except IndexError:
|
|
||||||
pass
|
|
||||||
if name is None:
|
|
||||||
return
|
|
||||||
if self.sbrPresentFlag == 1:
|
|
||||||
name += "+SBR"
|
|
||||||
if self.psPresentFlag == 1:
|
|
||||||
name += "+PS"
|
|
||||||
return text_type(name)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sample_rate(self):
|
|
||||||
"""0 means unknown"""
|
|
||||||
|
|
||||||
if self.sbrPresentFlag == 1:
|
|
||||||
return self.extensionSamplingFrequency
|
|
||||||
elif self.sbrPresentFlag == 0:
|
|
||||||
return self.samplingFrequency
|
|
||||||
else:
|
|
||||||
# these are all types that support SBR
|
|
||||||
aot_can_sbr = (1, 2, 3, 4, 6, 17, 19, 20, 22)
|
|
||||||
if self.audioObjectType not in aot_can_sbr:
|
|
||||||
return self.samplingFrequency
|
|
||||||
# there shouldn't be SBR for > 48KHz
|
|
||||||
if self.samplingFrequency > 24000:
|
|
||||||
return self.samplingFrequency
|
|
||||||
# either samplingFrequency or samplingFrequency * 2
|
|
||||||
return 0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def channels(self):
|
|
||||||
"""channel count or 0 for unknown"""
|
|
||||||
|
|
||||||
# from ProgramConfigElement()
|
|
||||||
if hasattr(self, "pce_channels"):
|
|
||||||
return self.pce_channels
|
|
||||||
|
|
||||||
conf = getattr(
|
|
||||||
self, "extensionChannelConfiguration", self.channelConfiguration)
|
|
||||||
|
|
||||||
if conf == 1:
|
|
||||||
if self.psPresentFlag == -1:
|
|
||||||
return 0
|
|
||||||
elif self.psPresentFlag == 1:
|
|
||||||
return 2
|
|
||||||
else:
|
|
||||||
return 1
|
|
||||||
elif conf == 7:
|
|
||||||
return 8
|
|
||||||
elif conf > 7:
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
return conf
|
|
||||||
|
|
||||||
def _get_audio_object_type(self, r):
|
|
||||||
"""Raises BitReaderError"""
|
|
||||||
|
|
||||||
audioObjectType = r.bits(5)
|
|
||||||
if audioObjectType == 31:
|
|
||||||
audioObjectTypeExt = r.bits(6)
|
|
||||||
audioObjectType = 32 + audioObjectTypeExt
|
|
||||||
return audioObjectType
|
|
||||||
|
|
||||||
def _get_sampling_freq(self, r):
|
|
||||||
"""Raises BitReaderError"""
|
|
||||||
|
|
||||||
samplingFrequencyIndex = r.bits(4)
|
|
||||||
if samplingFrequencyIndex == 0xf:
|
|
||||||
samplingFrequency = r.bits(24)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
samplingFrequency = self._FREQS[samplingFrequencyIndex]
|
|
||||||
except IndexError:
|
|
||||||
samplingFrequency = 0
|
|
||||||
return samplingFrequency
|
|
||||||
|
|
||||||
def __init__(self, fileobj, length):
|
|
||||||
"""Raises DescriptorError"""
|
|
||||||
|
|
||||||
r = BitReader(fileobj)
|
|
||||||
try:
|
|
||||||
self._parse(r, length)
|
|
||||||
except BitReaderError as e:
|
|
||||||
raise DescriptorError(e)
|
|
||||||
|
|
||||||
def _parse(self, r, length):
|
|
||||||
"""Raises BitReaderError"""
|
|
||||||
|
|
||||||
def bits_left():
|
|
||||||
return length * 8 - r.get_position()
|
|
||||||
|
|
||||||
self.audioObjectType = self._get_audio_object_type(r)
|
|
||||||
self.samplingFrequency = self._get_sampling_freq(r)
|
|
||||||
self.channelConfiguration = r.bits(4)
|
|
||||||
|
|
||||||
self.sbrPresentFlag = -1
|
|
||||||
self.psPresentFlag = -1
|
|
||||||
if self.audioObjectType in (5, 29):
|
|
||||||
self.extensionAudioObjectType = 5
|
|
||||||
self.sbrPresentFlag = 1
|
|
||||||
if self.audioObjectType == 29:
|
|
||||||
self.psPresentFlag = 1
|
|
||||||
self.extensionSamplingFrequency = self._get_sampling_freq(r)
|
|
||||||
self.audioObjectType = self._get_audio_object_type(r)
|
|
||||||
if self.audioObjectType == 22:
|
|
||||||
self.extensionChannelConfiguration = r.bits(4)
|
|
||||||
else:
|
|
||||||
self.extensionAudioObjectType = 0
|
|
||||||
|
|
||||||
if self.audioObjectType in (1, 2, 3, 4, 6, 7, 17, 19, 20, 21, 22, 23):
|
|
||||||
try:
|
|
||||||
GASpecificConfig(r, self)
|
|
||||||
except NotImplementedError:
|
|
||||||
# unsupported, (warn?)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
# unsupported
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.audioObjectType in (
|
|
||||||
17, 19, 20, 21, 22, 23, 24, 25, 26, 27, 39):
|
|
||||||
epConfig = r.bits(2)
|
|
||||||
if epConfig in (2, 3):
|
|
||||||
# unsupported
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.extensionAudioObjectType != 5 and bits_left() >= 16:
|
|
||||||
syncExtensionType = r.bits(11)
|
|
||||||
if syncExtensionType == 0x2b7:
|
|
||||||
self.extensionAudioObjectType = self._get_audio_object_type(r)
|
|
||||||
|
|
||||||
if self.extensionAudioObjectType == 5:
|
|
||||||
self.sbrPresentFlag = r.bits(1)
|
|
||||||
if self.sbrPresentFlag == 1:
|
|
||||||
self.extensionSamplingFrequency = \
|
|
||||||
self._get_sampling_freq(r)
|
|
||||||
if bits_left() >= 12:
|
|
||||||
syncExtensionType = r.bits(11)
|
|
||||||
if syncExtensionType == 0x548:
|
|
||||||
self.psPresentFlag = r.bits(1)
|
|
||||||
|
|
||||||
if self.extensionAudioObjectType == 22:
|
|
||||||
self.sbrPresentFlag = r.bits(1)
|
|
||||||
if self.sbrPresentFlag == 1:
|
|
||||||
self.extensionSamplingFrequency = \
|
|
||||||
self._get_sampling_freq(r)
|
|
||||||
self.extensionChannelConfiguration = r.bits(4)
|
|
||||||
|
|
||||||
|
|
||||||
def GASpecificConfig(r, info):
|
|
||||||
"""Reads GASpecificConfig which is needed to get the data after that
|
|
||||||
(there is no length defined to skip it) and to read program_config_element
|
|
||||||
which can contain channel counts.
|
|
||||||
|
|
||||||
May raise BitReaderError on error or
|
|
||||||
NotImplementedError if some reserved data was set.
|
|
||||||
"""
|
|
||||||
|
|
||||||
assert isinstance(info, DecoderSpecificInfo)
|
|
||||||
|
|
||||||
r.skip(1) # frameLengthFlag
|
|
||||||
dependsOnCoreCoder = r.bits(1)
|
|
||||||
if dependsOnCoreCoder:
|
|
||||||
r.skip(14)
|
|
||||||
extensionFlag = r.bits(1)
|
|
||||||
if not info.channelConfiguration:
|
|
||||||
pce = ProgramConfigElement(r)
|
|
||||||
info.pce_channels = pce.channels
|
|
||||||
if info.audioObjectType == 6 or info.audioObjectType == 20:
|
|
||||||
r.skip(3)
|
|
||||||
if extensionFlag:
|
|
||||||
if info.audioObjectType == 22:
|
|
||||||
r.skip(5 + 11)
|
|
||||||
if info.audioObjectType in (17, 19, 20, 23):
|
|
||||||
r.skip(1 + 1 + 1)
|
|
||||||
extensionFlag3 = r.bits(1)
|
|
||||||
if extensionFlag3 != 0:
|
|
||||||
raise NotImplementedError("extensionFlag3 set")
|
|
|
@ -1,194 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2006 Joe Wreschnig
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from mutagen._compat import PY2
|
|
||||||
|
|
||||||
# This is not an exhaustive list of container atoms, but just the
|
|
||||||
# ones this module needs to peek inside.
|
|
||||||
_CONTAINERS = [b"moov", b"udta", b"trak", b"mdia", b"meta", b"ilst",
|
|
||||||
b"stbl", b"minf", b"moof", b"traf"]
|
|
||||||
_SKIP_SIZE = {b"meta": 4}
|
|
||||||
|
|
||||||
|
|
||||||
class AtomError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Atom(object):
|
|
||||||
"""An individual atom.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
children -- list child atoms (or None for non-container atoms)
|
|
||||||
length -- length of this atom, including length and name
|
|
||||||
datalength = -- length of this atom without length, name
|
|
||||||
name -- four byte name of the atom, as a str
|
|
||||||
offset -- location in the constructor-given fileobj of this atom
|
|
||||||
|
|
||||||
This structure should only be used internally by Mutagen.
|
|
||||||
"""
|
|
||||||
|
|
||||||
children = None
|
|
||||||
|
|
||||||
def __init__(self, fileobj, level=0):
|
|
||||||
"""May raise AtomError"""
|
|
||||||
|
|
||||||
self.offset = fileobj.tell()
|
|
||||||
try:
|
|
||||||
self.length, self.name = struct.unpack(">I4s", fileobj.read(8))
|
|
||||||
except struct.error:
|
|
||||||
raise AtomError("truncated data")
|
|
||||||
self._dataoffset = self.offset + 8
|
|
||||||
if self.length == 1:
|
|
||||||
try:
|
|
||||||
self.length, = struct.unpack(">Q", fileobj.read(8))
|
|
||||||
except struct.error:
|
|
||||||
raise AtomError("truncated data")
|
|
||||||
self._dataoffset += 8
|
|
||||||
if self.length < 16:
|
|
||||||
raise AtomError(
|
|
||||||
"64 bit atom length can only be 16 and higher")
|
|
||||||
elif self.length == 0:
|
|
||||||
if level != 0:
|
|
||||||
raise AtomError(
|
|
||||||
"only a top-level atom can have zero length")
|
|
||||||
# Only the last atom is supposed to have a zero-length, meaning it
|
|
||||||
# extends to the end of file.
|
|
||||||
fileobj.seek(0, 2)
|
|
||||||
self.length = fileobj.tell() - self.offset
|
|
||||||
fileobj.seek(self.offset + 8, 0)
|
|
||||||
elif self.length < 8:
|
|
||||||
raise AtomError(
|
|
||||||
"atom length can only be 0, 1 or 8 and higher")
|
|
||||||
|
|
||||||
if self.name in _CONTAINERS:
|
|
||||||
self.children = []
|
|
||||||
fileobj.seek(_SKIP_SIZE.get(self.name, 0), 1)
|
|
||||||
while fileobj.tell() < self.offset + self.length:
|
|
||||||
self.children.append(Atom(fileobj, level + 1))
|
|
||||||
else:
|
|
||||||
fileobj.seek(self.offset + self.length, 0)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def datalength(self):
|
|
||||||
return self.length - (self._dataoffset - self.offset)
|
|
||||||
|
|
||||||
def read(self, fileobj):
|
|
||||||
"""Return if all data could be read and the atom payload"""
|
|
||||||
|
|
||||||
fileobj.seek(self._dataoffset, 0)
|
|
||||||
data = fileobj.read(self.datalength)
|
|
||||||
return len(data) == self.datalength, data
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def render(name, data):
|
|
||||||
"""Render raw atom data."""
|
|
||||||
# this raises OverflowError if Py_ssize_t can't handle the atom data
|
|
||||||
size = len(data) + 8
|
|
||||||
if size <= 0xFFFFFFFF:
|
|
||||||
return struct.pack(">I4s", size, name) + data
|
|
||||||
else:
|
|
||||||
return struct.pack(">I4sQ", 1, name, size + 8) + data
|
|
||||||
|
|
||||||
def findall(self, name, recursive=False):
|
|
||||||
"""Recursively find all child atoms by specified name."""
|
|
||||||
if self.children is not None:
|
|
||||||
for child in self.children:
|
|
||||||
if child.name == name:
|
|
||||||
yield child
|
|
||||||
if recursive:
|
|
||||||
for atom in child.findall(name, True):
|
|
||||||
yield atom
|
|
||||||
|
|
||||||
def __getitem__(self, remaining):
|
|
||||||
"""Look up a child atom, potentially recursively.
|
|
||||||
|
|
||||||
e.g. atom['udta', 'meta'] => <Atom name='meta' ...>
|
|
||||||
"""
|
|
||||||
if not remaining:
|
|
||||||
return self
|
|
||||||
elif self.children is None:
|
|
||||||
raise KeyError("%r is not a container" % self.name)
|
|
||||||
for child in self.children:
|
|
||||||
if child.name == remaining[0]:
|
|
||||||
return child[remaining[1:]]
|
|
||||||
else:
|
|
||||||
raise KeyError("%r not found" % remaining[0])
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
cls = self.__class__.__name__
|
|
||||||
if self.children is None:
|
|
||||||
return "<%s name=%r length=%r offset=%r>" % (
|
|
||||||
cls, self.name, self.length, self.offset)
|
|
||||||
else:
|
|
||||||
children = "\n".join([" " + line for child in self.children
|
|
||||||
for line in repr(child).splitlines()])
|
|
||||||
return "<%s name=%r length=%r offset=%r\n%s>" % (
|
|
||||||
cls, self.name, self.length, self.offset, children)
|
|
||||||
|
|
||||||
|
|
||||||
class Atoms(object):
|
|
||||||
"""Root atoms in a given file.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
atoms -- a list of top-level atoms as Atom objects
|
|
||||||
|
|
||||||
This structure should only be used internally by Mutagen.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
self.atoms = []
|
|
||||||
fileobj.seek(0, 2)
|
|
||||||
end = fileobj.tell()
|
|
||||||
fileobj.seek(0)
|
|
||||||
while fileobj.tell() + 8 <= end:
|
|
||||||
self.atoms.append(Atom(fileobj))
|
|
||||||
|
|
||||||
def path(self, *names):
|
|
||||||
"""Look up and return the complete path of an atom.
|
|
||||||
|
|
||||||
For example, atoms.path('moov', 'udta', 'meta') will return a
|
|
||||||
list of three atoms, corresponding to the moov, udta, and meta
|
|
||||||
atoms.
|
|
||||||
"""
|
|
||||||
|
|
||||||
path = [self]
|
|
||||||
for name in names:
|
|
||||||
path.append(path[-1][name, ])
|
|
||||||
return path[1:]
|
|
||||||
|
|
||||||
def __contains__(self, names):
|
|
||||||
try:
|
|
||||||
self[names]
|
|
||||||
except KeyError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def __getitem__(self, names):
|
|
||||||
"""Look up a child atom.
|
|
||||||
|
|
||||||
'names' may be a list of atoms (['moov', 'udta']) or a string
|
|
||||||
specifying the complete path ('moov.udta').
|
|
||||||
"""
|
|
||||||
|
|
||||||
if PY2:
|
|
||||||
if isinstance(names, basestring):
|
|
||||||
names = names.split(b".")
|
|
||||||
else:
|
|
||||||
if isinstance(names, bytes):
|
|
||||||
names = names.split(b".")
|
|
||||||
|
|
||||||
for child in self.atoms:
|
|
||||||
if child.name == names[0]:
|
|
||||||
return child[names[1:]]
|
|
||||||
else:
|
|
||||||
raise KeyError("%r not found" % names[0])
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return "\n".join([repr(child) for child in self.atoms])
|
|
|
@ -1,21 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright (C) 2014 Christoph Reiter
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
from mutagen._util import cdata
|
|
||||||
|
|
||||||
|
|
||||||
def parse_full_atom(data):
|
|
||||||
"""Some atoms are versioned. Split them up in (version, flags, payload).
|
|
||||||
Can raise ValueError.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if len(data) < 4:
|
|
||||||
raise ValueError("not enough data")
|
|
||||||
|
|
||||||
version = ord(data[0:1])
|
|
||||||
flags = cdata.uint_be(b"\x00" + data[1:4])
|
|
||||||
return version, flags, data[4:]
|
|
|
@ -1,270 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2006 Lukas Lalinsky
|
|
||||||
# Copyright (C) 2012 Christoph Reiter
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""Musepack audio streams with APEv2 tags.
|
|
||||||
|
|
||||||
Musepack is an audio format originally based on the MPEG-1 Layer-2
|
|
||||||
algorithms. Stream versions 4 through 7 are supported.
|
|
||||||
|
|
||||||
For more information, see http://www.musepack.net/.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__all__ = ["Musepack", "Open", "delete"]
|
|
||||||
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from ._compat import endswith, xrange
|
|
||||||
from mutagen import StreamInfo
|
|
||||||
from mutagen.apev2 import APEv2File, error, delete
|
|
||||||
from mutagen.id3 import BitPaddedInt
|
|
||||||
from mutagen._util import cdata
|
|
||||||
|
|
||||||
|
|
||||||
class MusepackHeaderError(error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
RATES = [44100, 48000, 37800, 32000]
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_sv8_int(fileobj, limit=9):
|
|
||||||
"""Reads (max limit) bytes from fileobj until the MSB is zero.
|
|
||||||
All 7 LSB will be merged to a big endian uint.
|
|
||||||
|
|
||||||
Raises ValueError in case not MSB is zero, or EOFError in
|
|
||||||
case the file ended before limit is reached.
|
|
||||||
|
|
||||||
Returns (parsed number, number of bytes read)
|
|
||||||
"""
|
|
||||||
|
|
||||||
num = 0
|
|
||||||
for i in xrange(limit):
|
|
||||||
c = fileobj.read(1)
|
|
||||||
if len(c) != 1:
|
|
||||||
raise EOFError
|
|
||||||
c = bytearray(c)
|
|
||||||
num = (num << 7) | (c[0] & 0x7F)
|
|
||||||
if not c[0] & 0x80:
|
|
||||||
return num, i + 1
|
|
||||||
if limit > 0:
|
|
||||||
raise ValueError
|
|
||||||
return 0, 0
|
|
||||||
|
|
||||||
|
|
||||||
def _calc_sv8_gain(gain):
|
|
||||||
# 64.82 taken from mpcdec
|
|
||||||
return 64.82 - gain / 256.0
|
|
||||||
|
|
||||||
|
|
||||||
def _calc_sv8_peak(peak):
|
|
||||||
return (10 ** (peak / (256.0 * 20.0)) / 65535.0)
|
|
||||||
|
|
||||||
|
|
||||||
class MusepackInfo(StreamInfo):
|
|
||||||
"""Musepack stream information.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
* channels -- number of audio channels
|
|
||||||
* length -- file length in seconds, as a float
|
|
||||||
* sample_rate -- audio sampling rate in Hz
|
|
||||||
* bitrate -- audio bitrate, in bits per second
|
|
||||||
* version -- Musepack stream version
|
|
||||||
|
|
||||||
Optional Attributes:
|
|
||||||
|
|
||||||
* title_gain, title_peak -- Replay Gain and peak data for this song
|
|
||||||
* album_gain, album_peak -- Replay Gain and peak data for this album
|
|
||||||
|
|
||||||
These attributes are only available in stream version 7/8. The
|
|
||||||
gains are a float, +/- some dB. The peaks are a percentage [0..1] of
|
|
||||||
the maximum amplitude. This means to get a number comparable to
|
|
||||||
VorbisGain, you must multiply the peak by 2.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
header = fileobj.read(4)
|
|
||||||
if len(header) != 4:
|
|
||||||
raise MusepackHeaderError("not a Musepack file")
|
|
||||||
|
|
||||||
# Skip ID3v2 tags
|
|
||||||
if header[:3] == b"ID3":
|
|
||||||
header = fileobj.read(6)
|
|
||||||
if len(header) != 6:
|
|
||||||
raise MusepackHeaderError("not a Musepack file")
|
|
||||||
size = 10 + BitPaddedInt(header[2:6])
|
|
||||||
fileobj.seek(size)
|
|
||||||
header = fileobj.read(4)
|
|
||||||
if len(header) != 4:
|
|
||||||
raise MusepackHeaderError("not a Musepack file")
|
|
||||||
|
|
||||||
if header.startswith(b"MPCK"):
|
|
||||||
self.__parse_sv8(fileobj)
|
|
||||||
else:
|
|
||||||
self.__parse_sv467(fileobj)
|
|
||||||
|
|
||||||
if not self.bitrate and self.length != 0:
|
|
||||||
fileobj.seek(0, 2)
|
|
||||||
self.bitrate = int(round(fileobj.tell() * 8 / self.length))
|
|
||||||
|
|
||||||
def __parse_sv8(self, fileobj):
|
|
||||||
# SV8 http://trac.musepack.net/trac/wiki/SV8Specification
|
|
||||||
|
|
||||||
key_size = 2
|
|
||||||
mandatory_packets = [b"SH", b"RG"]
|
|
||||||
|
|
||||||
def check_frame_key(key):
|
|
||||||
if ((len(frame_type) != key_size) or
|
|
||||||
(not b'AA' <= frame_type <= b'ZZ')):
|
|
||||||
raise MusepackHeaderError("Invalid frame key.")
|
|
||||||
|
|
||||||
frame_type = fileobj.read(key_size)
|
|
||||||
check_frame_key(frame_type)
|
|
||||||
|
|
||||||
while frame_type not in (b"AP", b"SE") and mandatory_packets:
|
|
||||||
try:
|
|
||||||
frame_size, slen = _parse_sv8_int(fileobj)
|
|
||||||
except (EOFError, ValueError):
|
|
||||||
raise MusepackHeaderError("Invalid packet size.")
|
|
||||||
data_size = frame_size - key_size - slen
|
|
||||||
# packets can be at maximum data_size big and are padded with zeros
|
|
||||||
|
|
||||||
if frame_type == b"SH":
|
|
||||||
mandatory_packets.remove(frame_type)
|
|
||||||
self.__parse_stream_header(fileobj, data_size)
|
|
||||||
elif frame_type == b"RG":
|
|
||||||
mandatory_packets.remove(frame_type)
|
|
||||||
self.__parse_replaygain_packet(fileobj, data_size)
|
|
||||||
else:
|
|
||||||
fileobj.seek(data_size, 1)
|
|
||||||
|
|
||||||
frame_type = fileobj.read(key_size)
|
|
||||||
check_frame_key(frame_type)
|
|
||||||
|
|
||||||
if mandatory_packets:
|
|
||||||
raise MusepackHeaderError("Missing mandatory packets: %s." %
|
|
||||||
", ".join(map(repr, mandatory_packets)))
|
|
||||||
|
|
||||||
self.length = float(self.samples) / self.sample_rate
|
|
||||||
self.bitrate = 0
|
|
||||||
|
|
||||||
def __parse_stream_header(self, fileobj, data_size):
|
|
||||||
# skip CRC
|
|
||||||
fileobj.seek(4, 1)
|
|
||||||
remaining_size = data_size - 4
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.version = bytearray(fileobj.read(1))[0]
|
|
||||||
except TypeError:
|
|
||||||
raise MusepackHeaderError("SH packet ended unexpectedly.")
|
|
||||||
|
|
||||||
remaining_size -= 1
|
|
||||||
|
|
||||||
try:
|
|
||||||
samples, l1 = _parse_sv8_int(fileobj)
|
|
||||||
samples_skip, l2 = _parse_sv8_int(fileobj)
|
|
||||||
except (EOFError, ValueError):
|
|
||||||
raise MusepackHeaderError(
|
|
||||||
"SH packet: Invalid sample counts.")
|
|
||||||
|
|
||||||
self.samples = samples - samples_skip
|
|
||||||
remaining_size -= l1 + l2
|
|
||||||
|
|
||||||
data = fileobj.read(remaining_size)
|
|
||||||
if len(data) != remaining_size:
|
|
||||||
raise MusepackHeaderError("SH packet ended unexpectedly.")
|
|
||||||
self.sample_rate = RATES[bytearray(data)[0] >> 5]
|
|
||||||
self.channels = (bytearray(data)[1] >> 4) + 1
|
|
||||||
|
|
||||||
def __parse_replaygain_packet(self, fileobj, data_size):
|
|
||||||
data = fileobj.read(data_size)
|
|
||||||
if data_size < 9:
|
|
||||||
raise MusepackHeaderError("Invalid RG packet size.")
|
|
||||||
if len(data) != data_size:
|
|
||||||
raise MusepackHeaderError("RG packet ended unexpectedly.")
|
|
||||||
title_gain = cdata.short_be(data[1:3])
|
|
||||||
title_peak = cdata.short_be(data[3:5])
|
|
||||||
album_gain = cdata.short_be(data[5:7])
|
|
||||||
album_peak = cdata.short_be(data[7:9])
|
|
||||||
if title_gain:
|
|
||||||
self.title_gain = _calc_sv8_gain(title_gain)
|
|
||||||
if title_peak:
|
|
||||||
self.title_peak = _calc_sv8_peak(title_peak)
|
|
||||||
if album_gain:
|
|
||||||
self.album_gain = _calc_sv8_gain(album_gain)
|
|
||||||
if album_peak:
|
|
||||||
self.album_peak = _calc_sv8_peak(album_peak)
|
|
||||||
|
|
||||||
def __parse_sv467(self, fileobj):
|
|
||||||
fileobj.seek(-4, 1)
|
|
||||||
header = fileobj.read(32)
|
|
||||||
if len(header) != 32:
|
|
||||||
raise MusepackHeaderError("not a Musepack file")
|
|
||||||
|
|
||||||
# SV7
|
|
||||||
if header.startswith(b"MP+"):
|
|
||||||
self.version = bytearray(header)[3] & 0xF
|
|
||||||
if self.version < 7:
|
|
||||||
raise MusepackHeaderError("not a Musepack file")
|
|
||||||
frames = cdata.uint_le(header[4:8])
|
|
||||||
flags = cdata.uint_le(header[8:12])
|
|
||||||
|
|
||||||
self.title_peak, self.title_gain = struct.unpack(
|
|
||||||
"<Hh", header[12:16])
|
|
||||||
self.album_peak, self.album_gain = struct.unpack(
|
|
||||||
"<Hh", header[16:20])
|
|
||||||
self.title_gain /= 100.0
|
|
||||||
self.album_gain /= 100.0
|
|
||||||
self.title_peak /= 65535.0
|
|
||||||
self.album_peak /= 65535.0
|
|
||||||
|
|
||||||
self.sample_rate = RATES[(flags >> 16) & 0x0003]
|
|
||||||
self.bitrate = 0
|
|
||||||
# SV4-SV6
|
|
||||||
else:
|
|
||||||
header_dword = cdata.uint_le(header[0:4])
|
|
||||||
self.version = (header_dword >> 11) & 0x03FF
|
|
||||||
if self.version < 4 or self.version > 6:
|
|
||||||
raise MusepackHeaderError("not a Musepack file")
|
|
||||||
self.bitrate = (header_dword >> 23) & 0x01FF
|
|
||||||
self.sample_rate = 44100
|
|
||||||
if self.version >= 5:
|
|
||||||
frames = cdata.uint_le(header[4:8])
|
|
||||||
else:
|
|
||||||
frames = cdata.ushort_le(header[6:8])
|
|
||||||
if self.version < 6:
|
|
||||||
frames -= 1
|
|
||||||
self.channels = 2
|
|
||||||
self.length = float(frames * 1152 - 576) / self.sample_rate
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
rg_data = []
|
|
||||||
if hasattr(self, "title_gain"):
|
|
||||||
rg_data.append(u"%+0.2f (title)" % self.title_gain)
|
|
||||||
if hasattr(self, "album_gain"):
|
|
||||||
rg_data.append(u"%+0.2f (album)" % self.album_gain)
|
|
||||||
rg_data = (rg_data and ", Gain: " + ", ".join(rg_data)) or ""
|
|
||||||
|
|
||||||
return u"Musepack SV%d, %.2f seconds, %d Hz, %d bps%s" % (
|
|
||||||
self.version, self.length, self.sample_rate, self.bitrate, rg_data)
|
|
||||||
|
|
||||||
|
|
||||||
class Musepack(APEv2File):
|
|
||||||
_Info = MusepackInfo
|
|
||||||
_mimes = ["audio/x-musepack", "audio/x-mpc"]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score(filename, fileobj, header):
|
|
||||||
filename = filename.lower()
|
|
||||||
|
|
||||||
return (header.startswith(b"MP+") + header.startswith(b"MPCK") +
|
|
||||||
endswith(filename, b".mpc"))
|
|
||||||
|
|
||||||
|
|
||||||
Open = Musepack
|
|
|
@ -1,548 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2006 Joe Wreschnig
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""Read and write Ogg bitstreams and pages.
|
|
||||||
|
|
||||||
This module reads and writes a subset of the Ogg bitstream format
|
|
||||||
version 0. It does *not* read or write Ogg Vorbis files! For that,
|
|
||||||
you should use mutagen.oggvorbis.
|
|
||||||
|
|
||||||
This implementation is based on the RFC 3533 standard found at
|
|
||||||
http://www.xiph.org/ogg/doc/rfc3533.txt.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import zlib
|
|
||||||
|
|
||||||
from mutagen import FileType
|
|
||||||
from mutagen._util import cdata, resize_bytes, MutagenError
|
|
||||||
from ._compat import cBytesIO, reraise, chr_, izip, xrange
|
|
||||||
|
|
||||||
|
|
||||||
class error(IOError, MutagenError):
|
|
||||||
"""Ogg stream parsing errors."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class OggPage(object):
|
|
||||||
"""A single Ogg page (not necessarily a single encoded packet).
|
|
||||||
|
|
||||||
A page is a header of 26 bytes, followed by the length of the
|
|
||||||
data, followed by the data.
|
|
||||||
|
|
||||||
The constructor is givin a file-like object pointing to the start
|
|
||||||
of an Ogg page. After the constructor is finished it is pointing
|
|
||||||
to the start of the next page.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
* version -- stream structure version (currently always 0)
|
|
||||||
* position -- absolute stream position (default -1)
|
|
||||||
* serial -- logical stream serial number (default 0)
|
|
||||||
* sequence -- page sequence number within logical stream (default 0)
|
|
||||||
* offset -- offset this page was read from (default None)
|
|
||||||
* complete -- if the last packet on this page is complete (default True)
|
|
||||||
* packets -- list of raw packet data (default [])
|
|
||||||
|
|
||||||
Note that if 'complete' is false, the next page's 'continued'
|
|
||||||
property must be true (so set both when constructing pages).
|
|
||||||
|
|
||||||
If a file-like object is supplied to the constructor, the above
|
|
||||||
attributes will be filled in based on it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
version = 0
|
|
||||||
__type_flags = 0
|
|
||||||
position = 0
|
|
||||||
serial = 0
|
|
||||||
sequence = 0
|
|
||||||
offset = None
|
|
||||||
complete = True
|
|
||||||
|
|
||||||
def __init__(self, fileobj=None):
|
|
||||||
self.packets = []
|
|
||||||
|
|
||||||
if fileobj is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.offset = fileobj.tell()
|
|
||||||
|
|
||||||
header = fileobj.read(27)
|
|
||||||
if len(header) == 0:
|
|
||||||
raise EOFError
|
|
||||||
|
|
||||||
try:
|
|
||||||
(oggs, self.version, self.__type_flags,
|
|
||||||
self.position, self.serial, self.sequence,
|
|
||||||
crc, segments) = struct.unpack("<4sBBqIIiB", header)
|
|
||||||
except struct.error:
|
|
||||||
raise error("unable to read full header; got %r" % header)
|
|
||||||
|
|
||||||
if oggs != b"OggS":
|
|
||||||
raise error("read %r, expected %r, at 0x%x" % (
|
|
||||||
oggs, b"OggS", fileobj.tell() - 27))
|
|
||||||
|
|
||||||
if self.version != 0:
|
|
||||||
raise error("version %r unsupported" % self.version)
|
|
||||||
|
|
||||||
total = 0
|
|
||||||
lacings = []
|
|
||||||
lacing_bytes = fileobj.read(segments)
|
|
||||||
if len(lacing_bytes) != segments:
|
|
||||||
raise error("unable to read %r lacing bytes" % segments)
|
|
||||||
for c in bytearray(lacing_bytes):
|
|
||||||
total += c
|
|
||||||
if c < 255:
|
|
||||||
lacings.append(total)
|
|
||||||
total = 0
|
|
||||||
if total:
|
|
||||||
lacings.append(total)
|
|
||||||
self.complete = False
|
|
||||||
|
|
||||||
self.packets = [fileobj.read(l) for l in lacings]
|
|
||||||
if [len(p) for p in self.packets] != lacings:
|
|
||||||
raise error("unable to read full data")
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
"""Two Ogg pages are the same if they write the same data."""
|
|
||||||
try:
|
|
||||||
return (self.write() == other.write())
|
|
||||||
except AttributeError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
__hash__ = object.__hash__
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
attrs = ['version', 'position', 'serial', 'sequence', 'offset',
|
|
||||||
'complete', 'continued', 'first', 'last']
|
|
||||||
values = ["%s=%r" % (attr, getattr(self, attr)) for attr in attrs]
|
|
||||||
return "<%s %s, %d bytes in %d packets>" % (
|
|
||||||
type(self).__name__, " ".join(values), sum(map(len, self.packets)),
|
|
||||||
len(self.packets))
|
|
||||||
|
|
||||||
def write(self):
|
|
||||||
"""Return a string encoding of the page header and data.
|
|
||||||
|
|
||||||
A ValueError is raised if the data is too big to fit in a
|
|
||||||
single page.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = [
|
|
||||||
struct.pack("<4sBBqIIi", b"OggS", self.version, self.__type_flags,
|
|
||||||
self.position, self.serial, self.sequence, 0)
|
|
||||||
]
|
|
||||||
|
|
||||||
lacing_data = []
|
|
||||||
for datum in self.packets:
|
|
||||||
quot, rem = divmod(len(datum), 255)
|
|
||||||
lacing_data.append(b"\xff" * quot + chr_(rem))
|
|
||||||
lacing_data = b"".join(lacing_data)
|
|
||||||
if not self.complete and lacing_data.endswith(b"\x00"):
|
|
||||||
lacing_data = lacing_data[:-1]
|
|
||||||
data.append(chr_(len(lacing_data)))
|
|
||||||
data.append(lacing_data)
|
|
||||||
data.extend(self.packets)
|
|
||||||
data = b"".join(data)
|
|
||||||
|
|
||||||
# Python's CRC is swapped relative to Ogg's needs.
|
|
||||||
# crc32 returns uint prior to py2.6 on some platforms, so force uint
|
|
||||||
crc = (~zlib.crc32(data.translate(cdata.bitswap), -1)) & 0xffffffff
|
|
||||||
# Although we're using to_uint_be, this actually makes the CRC
|
|
||||||
# a proper le integer, since Python's CRC is byteswapped.
|
|
||||||
crc = cdata.to_uint_be(crc).translate(cdata.bitswap)
|
|
||||||
data = data[:22] + crc + data[26:]
|
|
||||||
return data
|
|
||||||
|
|
||||||
@property
|
|
||||||
def size(self):
|
|
||||||
"""Total frame size."""
|
|
||||||
|
|
||||||
size = 27 # Initial header size
|
|
||||||
for datum in self.packets:
|
|
||||||
quot, rem = divmod(len(datum), 255)
|
|
||||||
size += quot + 1
|
|
||||||
if not self.complete and rem == 0:
|
|
||||||
# Packet contains a multiple of 255 bytes and is not
|
|
||||||
# terminated, so we don't have a \x00 at the end.
|
|
||||||
size -= 1
|
|
||||||
size += sum(map(len, self.packets))
|
|
||||||
return size
|
|
||||||
|
|
||||||
def __set_flag(self, bit, val):
|
|
||||||
mask = 1 << bit
|
|
||||||
if val:
|
|
||||||
self.__type_flags |= mask
|
|
||||||
else:
|
|
||||||
self.__type_flags &= ~mask
|
|
||||||
|
|
||||||
continued = property(
|
|
||||||
lambda self: cdata.test_bit(self.__type_flags, 0),
|
|
||||||
lambda self, v: self.__set_flag(0, v),
|
|
||||||
doc="The first packet is continued from the previous page.")
|
|
||||||
|
|
||||||
first = property(
|
|
||||||
lambda self: cdata.test_bit(self.__type_flags, 1),
|
|
||||||
lambda self, v: self.__set_flag(1, v),
|
|
||||||
doc="This is the first page of a logical bitstream.")
|
|
||||||
|
|
||||||
last = property(
|
|
||||||
lambda self: cdata.test_bit(self.__type_flags, 2),
|
|
||||||
lambda self, v: self.__set_flag(2, v),
|
|
||||||
doc="This is the last page of a logical bitstream.")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def renumber(fileobj, serial, start):
|
|
||||||
"""Renumber pages belonging to a specified logical stream.
|
|
||||||
|
|
||||||
fileobj must be opened with mode r+b or w+b.
|
|
||||||
|
|
||||||
Starting at page number 'start', renumber all pages belonging
|
|
||||||
to logical stream 'serial'. Other pages will be ignored.
|
|
||||||
|
|
||||||
fileobj must point to the start of a valid Ogg page; any
|
|
||||||
occuring after it and part of the specified logical stream
|
|
||||||
will be numbered. No adjustment will be made to the data in
|
|
||||||
the pages nor the granule position; only the page number, and
|
|
||||||
so also the CRC.
|
|
||||||
|
|
||||||
If an error occurs (e.g. non-Ogg data is found), fileobj will
|
|
||||||
be left pointing to the place in the stream the error occured,
|
|
||||||
but the invalid data will be left intact (since this function
|
|
||||||
does not change the total file size).
|
|
||||||
"""
|
|
||||||
|
|
||||||
number = start
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
except EOFError:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
if page.serial != serial:
|
|
||||||
# Wrong stream, skip this page.
|
|
||||||
continue
|
|
||||||
# Changing the number can't change the page size,
|
|
||||||
# so seeking back based on the current size is safe.
|
|
||||||
fileobj.seek(-page.size, 1)
|
|
||||||
page.sequence = number
|
|
||||||
fileobj.write(page.write())
|
|
||||||
fileobj.seek(page.offset + page.size, 0)
|
|
||||||
number += 1
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def to_packets(pages, strict=False):
|
|
||||||
"""Construct a list of packet data from a list of Ogg pages.
|
|
||||||
|
|
||||||
If strict is true, the first page must start a new packet,
|
|
||||||
and the last page must end the last packet.
|
|
||||||
"""
|
|
||||||
|
|
||||||
serial = pages[0].serial
|
|
||||||
sequence = pages[0].sequence
|
|
||||||
packets = []
|
|
||||||
|
|
||||||
if strict:
|
|
||||||
if pages[0].continued:
|
|
||||||
raise ValueError("first packet is continued")
|
|
||||||
if not pages[-1].complete:
|
|
||||||
raise ValueError("last packet does not complete")
|
|
||||||
elif pages and pages[0].continued:
|
|
||||||
packets.append([b""])
|
|
||||||
|
|
||||||
for page in pages:
|
|
||||||
if serial != page.serial:
|
|
||||||
raise ValueError("invalid serial number in %r" % page)
|
|
||||||
elif sequence != page.sequence:
|
|
||||||
raise ValueError("bad sequence number in %r" % page)
|
|
||||||
else:
|
|
||||||
sequence += 1
|
|
||||||
|
|
||||||
if page.continued:
|
|
||||||
packets[-1].append(page.packets[0])
|
|
||||||
else:
|
|
||||||
packets.append([page.packets[0]])
|
|
||||||
packets.extend([p] for p in page.packets[1:])
|
|
||||||
|
|
||||||
return [b"".join(p) for p in packets]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_packets_try_preserve(cls, packets, old_pages):
|
|
||||||
"""Like from_packets but in case the size and number of the packets
|
|
||||||
is the same as in the given pages the layout of the pages will
|
|
||||||
be copied (the page size and number will match).
|
|
||||||
|
|
||||||
If the packets don't match this behaves like::
|
|
||||||
|
|
||||||
OggPage.from_packets(packets, sequence=old_pages[0].sequence)
|
|
||||||
"""
|
|
||||||
|
|
||||||
old_packets = cls.to_packets(old_pages)
|
|
||||||
|
|
||||||
if [len(p) for p in packets] != [len(p) for p in old_packets]:
|
|
||||||
# doesn't match, fall back
|
|
||||||
return cls.from_packets(packets, old_pages[0].sequence)
|
|
||||||
|
|
||||||
new_data = b"".join(packets)
|
|
||||||
new_pages = []
|
|
||||||
for old in old_pages:
|
|
||||||
new = OggPage()
|
|
||||||
new.sequence = old.sequence
|
|
||||||
new.complete = old.complete
|
|
||||||
new.continued = old.continued
|
|
||||||
new.position = old.position
|
|
||||||
for p in old.packets:
|
|
||||||
data, new_data = new_data[:len(p)], new_data[len(p):]
|
|
||||||
new.packets.append(data)
|
|
||||||
new_pages.append(new)
|
|
||||||
assert not new_data
|
|
||||||
|
|
||||||
return new_pages
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_packets(packets, sequence=0, default_size=4096,
|
|
||||||
wiggle_room=2048):
|
|
||||||
"""Construct a list of Ogg pages from a list of packet data.
|
|
||||||
|
|
||||||
The algorithm will generate pages of approximately
|
|
||||||
default_size in size (rounded down to the nearest multiple of
|
|
||||||
255). However, it will also allow pages to increase to
|
|
||||||
approximately default_size + wiggle_room if allowing the
|
|
||||||
wiggle room would finish a packet (only one packet will be
|
|
||||||
finished in this way per page; if the next packet would fit
|
|
||||||
into the wiggle room, it still starts on a new page).
|
|
||||||
|
|
||||||
This method reduces packet fragmentation when packet sizes are
|
|
||||||
slightly larger than the default page size, while still
|
|
||||||
ensuring most pages are of the average size.
|
|
||||||
|
|
||||||
Pages are numbered started at 'sequence'; other information is
|
|
||||||
uninitialized.
|
|
||||||
"""
|
|
||||||
|
|
||||||
chunk_size = (default_size // 255) * 255
|
|
||||||
|
|
||||||
pages = []
|
|
||||||
|
|
||||||
page = OggPage()
|
|
||||||
page.sequence = sequence
|
|
||||||
|
|
||||||
for packet in packets:
|
|
||||||
page.packets.append(b"")
|
|
||||||
while packet:
|
|
||||||
data, packet = packet[:chunk_size], packet[chunk_size:]
|
|
||||||
if page.size < default_size and len(page.packets) < 255:
|
|
||||||
page.packets[-1] += data
|
|
||||||
else:
|
|
||||||
# If we've put any packet data into this page yet,
|
|
||||||
# we need to mark it incomplete. However, we can
|
|
||||||
# also have just started this packet on an already
|
|
||||||
# full page, in which case, just start the new
|
|
||||||
# page with this packet.
|
|
||||||
if page.packets[-1]:
|
|
||||||
page.complete = False
|
|
||||||
if len(page.packets) == 1:
|
|
||||||
page.position = -1
|
|
||||||
else:
|
|
||||||
page.packets.pop(-1)
|
|
||||||
pages.append(page)
|
|
||||||
page = OggPage()
|
|
||||||
page.continued = not pages[-1].complete
|
|
||||||
page.sequence = pages[-1].sequence + 1
|
|
||||||
page.packets.append(data)
|
|
||||||
|
|
||||||
if len(packet) < wiggle_room:
|
|
||||||
page.packets[-1] += packet
|
|
||||||
packet = b""
|
|
||||||
|
|
||||||
if page.packets:
|
|
||||||
pages.append(page)
|
|
||||||
|
|
||||||
return pages
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def replace(cls, fileobj, old_pages, new_pages):
|
|
||||||
"""Replace old_pages with new_pages within fileobj.
|
|
||||||
|
|
||||||
old_pages must have come from reading fileobj originally.
|
|
||||||
new_pages are assumed to have the 'same' data as old_pages,
|
|
||||||
and so the serial and sequence numbers will be copied, as will
|
|
||||||
the flags for the first and last pages.
|
|
||||||
|
|
||||||
fileobj will be resized and pages renumbered as necessary. As
|
|
||||||
such, it must be opened r+b or w+b.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not len(old_pages) or not len(new_pages):
|
|
||||||
raise ValueError("empty pages list not allowed")
|
|
||||||
|
|
||||||
# Number the new pages starting from the first old page.
|
|
||||||
first = old_pages[0].sequence
|
|
||||||
for page, seq in izip(new_pages,
|
|
||||||
xrange(first, first + len(new_pages))):
|
|
||||||
page.sequence = seq
|
|
||||||
page.serial = old_pages[0].serial
|
|
||||||
|
|
||||||
new_pages[0].first = old_pages[0].first
|
|
||||||
new_pages[0].last = old_pages[0].last
|
|
||||||
new_pages[0].continued = old_pages[0].continued
|
|
||||||
|
|
||||||
new_pages[-1].first = old_pages[-1].first
|
|
||||||
new_pages[-1].last = old_pages[-1].last
|
|
||||||
new_pages[-1].complete = old_pages[-1].complete
|
|
||||||
if not new_pages[-1].complete and len(new_pages[-1].packets) == 1:
|
|
||||||
new_pages[-1].position = -1
|
|
||||||
|
|
||||||
new_data = [cls.write(p) for p in new_pages]
|
|
||||||
|
|
||||||
# Add dummy data or merge the remaining data together so multiple
|
|
||||||
# new pages replace an old one
|
|
||||||
pages_diff = len(old_pages) - len(new_data)
|
|
||||||
if pages_diff > 0:
|
|
||||||
new_data.extend([b""] * pages_diff)
|
|
||||||
elif pages_diff < 0:
|
|
||||||
new_data[pages_diff - 1:] = [b"".join(new_data[pages_diff - 1:])]
|
|
||||||
|
|
||||||
# Replace pages one by one. If the sizes match no resize happens.
|
|
||||||
offset_adjust = 0
|
|
||||||
new_data_end = None
|
|
||||||
assert len(old_pages) == len(new_data)
|
|
||||||
for old_page, data in izip(old_pages, new_data):
|
|
||||||
offset = old_page.offset + offset_adjust
|
|
||||||
data_size = len(data)
|
|
||||||
resize_bytes(fileobj, old_page.size, data_size, offset)
|
|
||||||
fileobj.seek(offset, 0)
|
|
||||||
fileobj.write(data)
|
|
||||||
new_data_end = offset + data_size
|
|
||||||
offset_adjust += (data_size - old_page.size)
|
|
||||||
|
|
||||||
# Finally, if there's any discrepency in length, we need to
|
|
||||||
# renumber the pages for the logical stream.
|
|
||||||
if len(old_pages) != len(new_pages):
|
|
||||||
fileobj.seek(new_data_end, 0)
|
|
||||||
serial = new_pages[-1].serial
|
|
||||||
sequence = new_pages[-1].sequence + 1
|
|
||||||
cls.renumber(fileobj, serial, sequence)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def find_last(fileobj, serial):
|
|
||||||
"""Find the last page of the stream 'serial'.
|
|
||||||
|
|
||||||
If the file is not multiplexed this function is fast. If it is,
|
|
||||||
it must read the whole the stream.
|
|
||||||
|
|
||||||
This finds the last page in the actual file object, or the last
|
|
||||||
page in the stream (with eos set), whichever comes first.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# For non-muxed streams, look at the last page.
|
|
||||||
try:
|
|
||||||
fileobj.seek(-256 * 256, 2)
|
|
||||||
except IOError:
|
|
||||||
# The file is less than 64k in length.
|
|
||||||
fileobj.seek(0)
|
|
||||||
data = fileobj.read()
|
|
||||||
try:
|
|
||||||
index = data.rindex(b"OggS")
|
|
||||||
except ValueError:
|
|
||||||
raise error("unable to find final Ogg header")
|
|
||||||
bytesobj = cBytesIO(data[index:])
|
|
||||||
best_page = None
|
|
||||||
try:
|
|
||||||
page = OggPage(bytesobj)
|
|
||||||
except error:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if page.serial == serial:
|
|
||||||
if page.last:
|
|
||||||
return page
|
|
||||||
else:
|
|
||||||
best_page = page
|
|
||||||
else:
|
|
||||||
best_page = None
|
|
||||||
|
|
||||||
# The stream is muxed, so use the slow way.
|
|
||||||
fileobj.seek(0)
|
|
||||||
try:
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
while not page.last:
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
while page.serial != serial:
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
best_page = page
|
|
||||||
return page
|
|
||||||
except error:
|
|
||||||
return best_page
|
|
||||||
except EOFError:
|
|
||||||
return best_page
|
|
||||||
|
|
||||||
|
|
||||||
class OggFileType(FileType):
|
|
||||||
"""An generic Ogg file."""
|
|
||||||
|
|
||||||
_Info = None
|
|
||||||
_Tags = None
|
|
||||||
_Error = None
|
|
||||||
_mimes = ["application/ogg", "application/x-ogg"]
|
|
||||||
|
|
||||||
def load(self, filename):
|
|
||||||
"""Load file information from a filename."""
|
|
||||||
|
|
||||||
self.filename = filename
|
|
||||||
with open(filename, "rb") as fileobj:
|
|
||||||
try:
|
|
||||||
self.info = self._Info(fileobj)
|
|
||||||
self.tags = self._Tags(fileobj, self.info)
|
|
||||||
self.info._post_tags(fileobj)
|
|
||||||
except error as e:
|
|
||||||
reraise(self._Error, e, sys.exc_info()[2])
|
|
||||||
except EOFError:
|
|
||||||
raise self._Error("no appropriate stream found")
|
|
||||||
|
|
||||||
def delete(self, filename=None):
|
|
||||||
"""Remove tags from a file.
|
|
||||||
|
|
||||||
If no filename is given, the one most recently loaded is used.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if filename is None:
|
|
||||||
filename = self.filename
|
|
||||||
|
|
||||||
self.tags.clear()
|
|
||||||
# TODO: we should delegate the deletion to the subclass and not through
|
|
||||||
# _inject.
|
|
||||||
with open(filename, "rb+") as fileobj:
|
|
||||||
try:
|
|
||||||
self.tags._inject(fileobj, lambda x: 0)
|
|
||||||
except error as e:
|
|
||||||
reraise(self._Error, e, sys.exc_info()[2])
|
|
||||||
except EOFError:
|
|
||||||
raise self._Error("no appropriate stream found")
|
|
||||||
|
|
||||||
def add_tags(self):
|
|
||||||
raise self._Error
|
|
||||||
|
|
||||||
def save(self, filename=None, padding=None):
|
|
||||||
"""Save a tag to a file.
|
|
||||||
|
|
||||||
If no filename is given, the one most recently loaded is used.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if filename is None:
|
|
||||||
filename = self.filename
|
|
||||||
fileobj = open(filename, "rb+")
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
self.tags._inject(fileobj, padding)
|
|
||||||
except error as e:
|
|
||||||
reraise(self._Error, e, sys.exc_info()[2])
|
|
||||||
except EOFError:
|
|
||||||
raise self._Error("no appropriate stream found")
|
|
||||||
finally:
|
|
||||||
fileobj.close()
|
|
|
@ -1,161 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2006 Joe Wreschnig
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""Read and write Ogg FLAC comments.
|
|
||||||
|
|
||||||
This module handles FLAC files wrapped in an Ogg bitstream. The first
|
|
||||||
FLAC stream found is used. For 'naked' FLACs, see mutagen.flac.
|
|
||||||
|
|
||||||
This module is based off the specification at
|
|
||||||
http://flac.sourceforge.net/ogg_mapping.html.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__all__ = ["OggFLAC", "Open", "delete"]
|
|
||||||
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from ._compat import cBytesIO
|
|
||||||
|
|
||||||
from mutagen import StreamInfo
|
|
||||||
from mutagen.flac import StreamInfo as FLACStreamInfo, error as FLACError
|
|
||||||
from mutagen._vorbis import VCommentDict
|
|
||||||
from mutagen.ogg import OggPage, OggFileType, error as OggError
|
|
||||||
|
|
||||||
|
|
||||||
class error(OggError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class OggFLACHeaderError(error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class OggFLACStreamInfo(StreamInfo):
|
|
||||||
"""Ogg FLAC stream info."""
|
|
||||||
|
|
||||||
length = 0
|
|
||||||
"""File length in seconds, as a float"""
|
|
||||||
|
|
||||||
channels = 0
|
|
||||||
"""Number of channels"""
|
|
||||||
|
|
||||||
sample_rate = 0
|
|
||||||
"""Sample rate in Hz"""
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
while not page.packets[0].startswith(b"\x7FFLAC"):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
major, minor, self.packets, flac = struct.unpack(
|
|
||||||
">BBH4s", page.packets[0][5:13])
|
|
||||||
if flac != b"fLaC":
|
|
||||||
raise OggFLACHeaderError("invalid FLAC marker (%r)" % flac)
|
|
||||||
elif (major, minor) != (1, 0):
|
|
||||||
raise OggFLACHeaderError(
|
|
||||||
"unknown mapping version: %d.%d" % (major, minor))
|
|
||||||
self.serial = page.serial
|
|
||||||
|
|
||||||
# Skip over the block header.
|
|
||||||
stringobj = cBytesIO(page.packets[0][17:])
|
|
||||||
|
|
||||||
try:
|
|
||||||
flac_info = FLACStreamInfo(stringobj)
|
|
||||||
except FLACError as e:
|
|
||||||
raise OggFLACHeaderError(e)
|
|
||||||
|
|
||||||
for attr in ["min_blocksize", "max_blocksize", "sample_rate",
|
|
||||||
"channels", "bits_per_sample", "total_samples", "length"]:
|
|
||||||
setattr(self, attr, getattr(flac_info, attr))
|
|
||||||
|
|
||||||
def _post_tags(self, fileobj):
|
|
||||||
if self.length:
|
|
||||||
return
|
|
||||||
page = OggPage.find_last(fileobj, self.serial)
|
|
||||||
self.length = page.position / float(self.sample_rate)
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
return u"Ogg FLAC, %.2f seconds, %d Hz" % (
|
|
||||||
self.length, self.sample_rate)
|
|
||||||
|
|
||||||
|
|
||||||
class OggFLACVComment(VCommentDict):
|
|
||||||
|
|
||||||
def __init__(self, fileobj, info):
|
|
||||||
# data should be pointing at the start of an Ogg page, after
|
|
||||||
# the first FLAC page.
|
|
||||||
pages = []
|
|
||||||
complete = False
|
|
||||||
while not complete:
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
if page.serial == info.serial:
|
|
||||||
pages.append(page)
|
|
||||||
complete = page.complete or (len(page.packets) > 1)
|
|
||||||
comment = cBytesIO(OggPage.to_packets(pages)[0][4:])
|
|
||||||
super(OggFLACVComment, self).__init__(comment, framing=False)
|
|
||||||
|
|
||||||
def _inject(self, fileobj, padding_func):
|
|
||||||
"""Write tag data into the FLAC Vorbis comment packet/page."""
|
|
||||||
|
|
||||||
# Ogg FLAC has no convenient data marker like Vorbis, but the
|
|
||||||
# second packet - and second page - must be the comment data.
|
|
||||||
fileobj.seek(0)
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
while not page.packets[0].startswith(b"\x7FFLAC"):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
|
|
||||||
first_page = page
|
|
||||||
while not (page.sequence == 1 and page.serial == first_page.serial):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
|
|
||||||
old_pages = [page]
|
|
||||||
while not (old_pages[-1].complete or len(old_pages[-1].packets) > 1):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
if page.serial == first_page.serial:
|
|
||||||
old_pages.append(page)
|
|
||||||
|
|
||||||
packets = OggPage.to_packets(old_pages, strict=False)
|
|
||||||
|
|
||||||
# Set the new comment block.
|
|
||||||
data = self.write(framing=False)
|
|
||||||
data = packets[0][:1] + struct.pack(">I", len(data))[-3:] + data
|
|
||||||
packets[0] = data
|
|
||||||
|
|
||||||
new_pages = OggPage.from_packets(packets, old_pages[0].sequence)
|
|
||||||
OggPage.replace(fileobj, old_pages, new_pages)
|
|
||||||
|
|
||||||
|
|
||||||
class OggFLAC(OggFileType):
|
|
||||||
"""An Ogg FLAC file."""
|
|
||||||
|
|
||||||
_Info = OggFLACStreamInfo
|
|
||||||
_Tags = OggFLACVComment
|
|
||||||
_Error = OggFLACHeaderError
|
|
||||||
_mimes = ["audio/x-oggflac"]
|
|
||||||
|
|
||||||
info = None
|
|
||||||
"""A `OggFLACStreamInfo`"""
|
|
||||||
|
|
||||||
tags = None
|
|
||||||
"""A `VCommentDict`"""
|
|
||||||
|
|
||||||
def save(self, filename=None):
|
|
||||||
return super(OggFLAC, self).save(filename)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score(filename, fileobj, header):
|
|
||||||
return (header.startswith(b"OggS") * (
|
|
||||||
(b"FLAC" in header) + (b"fLaC" in header)))
|
|
||||||
|
|
||||||
|
|
||||||
Open = OggFLAC
|
|
||||||
|
|
||||||
|
|
||||||
def delete(filename):
|
|
||||||
"""Remove tags from a file."""
|
|
||||||
|
|
||||||
OggFLAC(filename).delete()
|
|
|
@ -1,158 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2012, 2013 Christoph Reiter
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""Read and write Ogg Opus comments.
|
|
||||||
|
|
||||||
This module handles Opus files wrapped in an Ogg bitstream. The
|
|
||||||
first Opus stream found is used.
|
|
||||||
|
|
||||||
Based on http://tools.ietf.org/html/draft-terriberry-oggopus-01
|
|
||||||
"""
|
|
||||||
|
|
||||||
__all__ = ["OggOpus", "Open", "delete"]
|
|
||||||
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from mutagen import StreamInfo
|
|
||||||
from mutagen._compat import BytesIO
|
|
||||||
from mutagen._util import get_size
|
|
||||||
from mutagen._tags import PaddingInfo
|
|
||||||
from mutagen._vorbis import VCommentDict
|
|
||||||
from mutagen.ogg import OggPage, OggFileType, error as OggError
|
|
||||||
|
|
||||||
|
|
||||||
class error(OggError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class OggOpusHeaderError(error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class OggOpusInfo(StreamInfo):
|
|
||||||
"""Ogg Opus stream information."""
|
|
||||||
|
|
||||||
length = 0
|
|
||||||
"""File length in seconds, as a float"""
|
|
||||||
|
|
||||||
channels = 0
|
|
||||||
"""Number of channels"""
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
while not page.packets[0].startswith(b"OpusHead"):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
|
|
||||||
self.serial = page.serial
|
|
||||||
|
|
||||||
if not page.first:
|
|
||||||
raise OggOpusHeaderError(
|
|
||||||
"page has ID header, but doesn't start a stream")
|
|
||||||
|
|
||||||
(version, self.channels, pre_skip, orig_sample_rate, output_gain,
|
|
||||||
channel_map) = struct.unpack("<BBHIhB", page.packets[0][8:19])
|
|
||||||
|
|
||||||
self.__pre_skip = pre_skip
|
|
||||||
|
|
||||||
# only the higher 4 bits change on incombatible changes
|
|
||||||
major = version >> 4
|
|
||||||
if major != 0:
|
|
||||||
raise OggOpusHeaderError("version %r unsupported" % major)
|
|
||||||
|
|
||||||
def _post_tags(self, fileobj):
|
|
||||||
page = OggPage.find_last(fileobj, self.serial)
|
|
||||||
self.length = (page.position - self.__pre_skip) / float(48000)
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
return u"Ogg Opus, %.2f seconds" % (self.length)
|
|
||||||
|
|
||||||
|
|
||||||
class OggOpusVComment(VCommentDict):
|
|
||||||
"""Opus comments embedded in an Ogg bitstream."""
|
|
||||||
|
|
||||||
def __get_comment_pages(self, fileobj, info):
|
|
||||||
# find the first tags page with the right serial
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
while ((info.serial != page.serial) or
|
|
||||||
not page.packets[0].startswith(b"OpusTags")):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
|
|
||||||
# get all comment pages
|
|
||||||
pages = [page]
|
|
||||||
while not (pages[-1].complete or len(pages[-1].packets) > 1):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
if page.serial == pages[0].serial:
|
|
||||||
pages.append(page)
|
|
||||||
|
|
||||||
return pages
|
|
||||||
|
|
||||||
def __init__(self, fileobj, info):
|
|
||||||
pages = self.__get_comment_pages(fileobj, info)
|
|
||||||
data = OggPage.to_packets(pages)[0][8:] # Strip OpusTags
|
|
||||||
fileobj = BytesIO(data)
|
|
||||||
super(OggOpusVComment, self).__init__(fileobj, framing=False)
|
|
||||||
self._padding = len(data) - self._size
|
|
||||||
|
|
||||||
# in case the LSB of the first byte after v-comment is 1, preserve the
|
|
||||||
# following data
|
|
||||||
padding_flag = fileobj.read(1)
|
|
||||||
if padding_flag and ord(padding_flag) & 0x1:
|
|
||||||
self._pad_data = padding_flag + fileobj.read()
|
|
||||||
self._padding = 0 # we have to preserve, so no padding
|
|
||||||
else:
|
|
||||||
self._pad_data = b""
|
|
||||||
|
|
||||||
def _inject(self, fileobj, padding_func):
|
|
||||||
fileobj.seek(0)
|
|
||||||
info = OggOpusInfo(fileobj)
|
|
||||||
old_pages = self.__get_comment_pages(fileobj, info)
|
|
||||||
|
|
||||||
packets = OggPage.to_packets(old_pages)
|
|
||||||
vcomment_data = b"OpusTags" + self.write(framing=False)
|
|
||||||
|
|
||||||
if self._pad_data:
|
|
||||||
# if we have padding data to preserver we can't add more padding
|
|
||||||
# as long as we don't know the structure of what follows
|
|
||||||
packets[0] = vcomment_data + self._pad_data
|
|
||||||
else:
|
|
||||||
content_size = get_size(fileobj) - len(packets[0]) # approx
|
|
||||||
padding_left = len(packets[0]) - len(vcomment_data)
|
|
||||||
info = PaddingInfo(padding_left, content_size)
|
|
||||||
new_padding = info._get_padding(padding_func)
|
|
||||||
packets[0] = vcomment_data + b"\x00" * new_padding
|
|
||||||
|
|
||||||
new_pages = OggPage._from_packets_try_preserve(packets, old_pages)
|
|
||||||
OggPage.replace(fileobj, old_pages, new_pages)
|
|
||||||
|
|
||||||
|
|
||||||
class OggOpus(OggFileType):
|
|
||||||
"""An Ogg Opus file."""
|
|
||||||
|
|
||||||
_Info = OggOpusInfo
|
|
||||||
_Tags = OggOpusVComment
|
|
||||||
_Error = OggOpusHeaderError
|
|
||||||
_mimes = ["audio/ogg", "audio/ogg; codecs=opus"]
|
|
||||||
|
|
||||||
info = None
|
|
||||||
"""A `OggOpusInfo`"""
|
|
||||||
|
|
||||||
tags = None
|
|
||||||
"""A `VCommentDict`"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score(filename, fileobj, header):
|
|
||||||
return (header.startswith(b"OggS") * (b"OpusHead" in header))
|
|
||||||
|
|
||||||
|
|
||||||
Open = OggOpus
|
|
||||||
|
|
||||||
|
|
||||||
def delete(filename):
|
|
||||||
"""Remove tags from a file."""
|
|
||||||
|
|
||||||
OggOpus(filename).delete()
|
|
|
@ -1,154 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright 2006 Joe Wreschnig
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""Read and write Ogg Speex comments.
|
|
||||||
|
|
||||||
This module handles Speex files wrapped in an Ogg bitstream. The
|
|
||||||
first Speex stream found is used.
|
|
||||||
|
|
||||||
Read more about Ogg Speex at http://www.speex.org/. This module is
|
|
||||||
based on the specification at http://www.speex.org/manual2/node7.html
|
|
||||||
and clarifications after personal communication with Jean-Marc,
|
|
||||||
http://lists.xiph.org/pipermail/speex-dev/2006-July/004676.html.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__all__ = ["OggSpeex", "Open", "delete"]
|
|
||||||
|
|
||||||
from mutagen import StreamInfo
|
|
||||||
from mutagen._vorbis import VCommentDict
|
|
||||||
from mutagen.ogg import OggPage, OggFileType, error as OggError
|
|
||||||
from mutagen._util import cdata, get_size
|
|
||||||
from mutagen._tags import PaddingInfo
|
|
||||||
|
|
||||||
|
|
||||||
class error(OggError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class OggSpeexHeaderError(error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class OggSpeexInfo(StreamInfo):
|
|
||||||
"""Ogg Speex stream information."""
|
|
||||||
|
|
||||||
length = 0
|
|
||||||
"""file length in seconds, as a float"""
|
|
||||||
|
|
||||||
channels = 0
|
|
||||||
"""number of channels"""
|
|
||||||
|
|
||||||
bitrate = 0
|
|
||||||
"""nominal bitrate in bits per second.
|
|
||||||
|
|
||||||
The reference encoder does not set the bitrate; in this case,
|
|
||||||
the bitrate will be 0.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
while not page.packets[0].startswith(b"Speex "):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
if not page.first:
|
|
||||||
raise OggSpeexHeaderError(
|
|
||||||
"page has ID header, but doesn't start a stream")
|
|
||||||
self.sample_rate = cdata.uint_le(page.packets[0][36:40])
|
|
||||||
self.channels = cdata.uint_le(page.packets[0][48:52])
|
|
||||||
self.bitrate = max(0, cdata.int_le(page.packets[0][52:56]))
|
|
||||||
self.serial = page.serial
|
|
||||||
|
|
||||||
def _post_tags(self, fileobj):
|
|
||||||
page = OggPage.find_last(fileobj, self.serial)
|
|
||||||
self.length = page.position / float(self.sample_rate)
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
return u"Ogg Speex, %.2f seconds" % self.length
|
|
||||||
|
|
||||||
|
|
||||||
class OggSpeexVComment(VCommentDict):
|
|
||||||
"""Speex comments embedded in an Ogg bitstream."""
|
|
||||||
|
|
||||||
def __init__(self, fileobj, info):
|
|
||||||
pages = []
|
|
||||||
complete = False
|
|
||||||
while not complete:
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
if page.serial == info.serial:
|
|
||||||
pages.append(page)
|
|
||||||
complete = page.complete or (len(page.packets) > 1)
|
|
||||||
data = OggPage.to_packets(pages)[0]
|
|
||||||
super(OggSpeexVComment, self).__init__(data, framing=False)
|
|
||||||
self._padding = len(data) - self._size
|
|
||||||
|
|
||||||
def _inject(self, fileobj, padding_func):
|
|
||||||
"""Write tag data into the Speex comment packet/page."""
|
|
||||||
|
|
||||||
fileobj.seek(0)
|
|
||||||
|
|
||||||
# Find the first header page, with the stream info.
|
|
||||||
# Use it to get the serial number.
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
while not page.packets[0].startswith(b"Speex "):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
|
|
||||||
# Look for the next page with that serial number, it'll start
|
|
||||||
# the comment packet.
|
|
||||||
serial = page.serial
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
while page.serial != serial:
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
|
|
||||||
# Then find all the pages with the comment packet.
|
|
||||||
old_pages = [page]
|
|
||||||
while not (old_pages[-1].complete or len(old_pages[-1].packets) > 1):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
if page.serial == old_pages[0].serial:
|
|
||||||
old_pages.append(page)
|
|
||||||
|
|
||||||
packets = OggPage.to_packets(old_pages, strict=False)
|
|
||||||
|
|
||||||
content_size = get_size(fileobj) - len(packets[0]) # approx
|
|
||||||
vcomment_data = self.write(framing=False)
|
|
||||||
padding_left = len(packets[0]) - len(vcomment_data)
|
|
||||||
|
|
||||||
info = PaddingInfo(padding_left, content_size)
|
|
||||||
new_padding = info._get_padding(padding_func)
|
|
||||||
|
|
||||||
# Set the new comment packet.
|
|
||||||
packets[0] = vcomment_data + b"\x00" * new_padding
|
|
||||||
|
|
||||||
new_pages = OggPage._from_packets_try_preserve(packets, old_pages)
|
|
||||||
OggPage.replace(fileobj, old_pages, new_pages)
|
|
||||||
|
|
||||||
|
|
||||||
class OggSpeex(OggFileType):
|
|
||||||
"""An Ogg Speex file."""
|
|
||||||
|
|
||||||
_Info = OggSpeexInfo
|
|
||||||
_Tags = OggSpeexVComment
|
|
||||||
_Error = OggSpeexHeaderError
|
|
||||||
_mimes = ["audio/x-speex"]
|
|
||||||
|
|
||||||
info = None
|
|
||||||
"""A `OggSpeexInfo`"""
|
|
||||||
|
|
||||||
tags = None
|
|
||||||
"""A `VCommentDict`"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score(filename, fileobj, header):
|
|
||||||
return (header.startswith(b"OggS") * (b"Speex " in header))
|
|
||||||
|
|
||||||
|
|
||||||
Open = OggSpeex
|
|
||||||
|
|
||||||
|
|
||||||
def delete(filename):
|
|
||||||
"""Remove tags from a file."""
|
|
||||||
|
|
||||||
OggSpeex(filename).delete()
|
|
|
@ -1,148 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright 2006 Joe Wreschnig
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""Read and write Ogg Theora comments.
|
|
||||||
|
|
||||||
This module handles Theora files wrapped in an Ogg bitstream. The
|
|
||||||
first Theora stream found is used.
|
|
||||||
|
|
||||||
Based on the specification at http://theora.org/doc/Theora_I_spec.pdf.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__all__ = ["OggTheora", "Open", "delete"]
|
|
||||||
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from mutagen import StreamInfo
|
|
||||||
from mutagen._vorbis import VCommentDict
|
|
||||||
from mutagen._util import cdata, get_size
|
|
||||||
from mutagen._tags import PaddingInfo
|
|
||||||
from mutagen.ogg import OggPage, OggFileType, error as OggError
|
|
||||||
|
|
||||||
|
|
||||||
class error(OggError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class OggTheoraHeaderError(error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class OggTheoraInfo(StreamInfo):
|
|
||||||
"""Ogg Theora stream information."""
|
|
||||||
|
|
||||||
length = 0
|
|
||||||
"""File length in seconds, as a float"""
|
|
||||||
|
|
||||||
fps = 0
|
|
||||||
"""Video frames per second, as a float"""
|
|
||||||
|
|
||||||
bitrate = 0
|
|
||||||
"""Bitrate in bps (int)"""
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
while not page.packets[0].startswith(b"\x80theora"):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
if not page.first:
|
|
||||||
raise OggTheoraHeaderError(
|
|
||||||
"page has ID header, but doesn't start a stream")
|
|
||||||
data = page.packets[0]
|
|
||||||
vmaj, vmin = struct.unpack("2B", data[7:9])
|
|
||||||
if (vmaj, vmin) != (3, 2):
|
|
||||||
raise OggTheoraHeaderError(
|
|
||||||
"found Theora version %d.%d != 3.2" % (vmaj, vmin))
|
|
||||||
fps_num, fps_den = struct.unpack(">2I", data[22:30])
|
|
||||||
self.fps = fps_num / float(fps_den)
|
|
||||||
self.bitrate = cdata.uint_be(b"\x00" + data[37:40])
|
|
||||||
self.granule_shift = (cdata.ushort_be(data[40:42]) >> 5) & 0x1F
|
|
||||||
self.serial = page.serial
|
|
||||||
|
|
||||||
def _post_tags(self, fileobj):
|
|
||||||
page = OggPage.find_last(fileobj, self.serial)
|
|
||||||
position = page.position
|
|
||||||
mask = (1 << self.granule_shift) - 1
|
|
||||||
frames = (position >> self.granule_shift) + (position & mask)
|
|
||||||
self.length = frames / float(self.fps)
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
return u"Ogg Theora, %.2f seconds, %d bps" % (self.length,
|
|
||||||
self.bitrate)
|
|
||||||
|
|
||||||
|
|
||||||
class OggTheoraCommentDict(VCommentDict):
|
|
||||||
"""Theora comments embedded in an Ogg bitstream."""
|
|
||||||
|
|
||||||
def __init__(self, fileobj, info):
|
|
||||||
pages = []
|
|
||||||
complete = False
|
|
||||||
while not complete:
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
if page.serial == info.serial:
|
|
||||||
pages.append(page)
|
|
||||||
complete = page.complete or (len(page.packets) > 1)
|
|
||||||
data = OggPage.to_packets(pages)[0][7:]
|
|
||||||
super(OggTheoraCommentDict, self).__init__(data, framing=False)
|
|
||||||
self._padding = len(data) - self._size
|
|
||||||
|
|
||||||
def _inject(self, fileobj, padding_func):
|
|
||||||
"""Write tag data into the Theora comment packet/page."""
|
|
||||||
|
|
||||||
fileobj.seek(0)
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
while not page.packets[0].startswith(b"\x81theora"):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
|
|
||||||
old_pages = [page]
|
|
||||||
while not (old_pages[-1].complete or len(old_pages[-1].packets) > 1):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
if page.serial == old_pages[0].serial:
|
|
||||||
old_pages.append(page)
|
|
||||||
|
|
||||||
packets = OggPage.to_packets(old_pages, strict=False)
|
|
||||||
|
|
||||||
content_size = get_size(fileobj) - len(packets[0]) # approx
|
|
||||||
vcomment_data = b"\x81theora" + self.write(framing=False)
|
|
||||||
padding_left = len(packets[0]) - len(vcomment_data)
|
|
||||||
|
|
||||||
info = PaddingInfo(padding_left, content_size)
|
|
||||||
new_padding = info._get_padding(padding_func)
|
|
||||||
|
|
||||||
packets[0] = vcomment_data + b"\x00" * new_padding
|
|
||||||
|
|
||||||
new_pages = OggPage._from_packets_try_preserve(packets, old_pages)
|
|
||||||
OggPage.replace(fileobj, old_pages, new_pages)
|
|
||||||
|
|
||||||
|
|
||||||
class OggTheora(OggFileType):
|
|
||||||
"""An Ogg Theora file."""
|
|
||||||
|
|
||||||
_Info = OggTheoraInfo
|
|
||||||
_Tags = OggTheoraCommentDict
|
|
||||||
_Error = OggTheoraHeaderError
|
|
||||||
_mimes = ["video/x-theora"]
|
|
||||||
|
|
||||||
info = None
|
|
||||||
"""A `OggTheoraInfo`"""
|
|
||||||
|
|
||||||
tags = None
|
|
||||||
"""A `VCommentDict`"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score(filename, fileobj, header):
|
|
||||||
return (header.startswith(b"OggS") *
|
|
||||||
((b"\x80theora" in header) + (b"\x81theora" in header)) * 2)
|
|
||||||
|
|
||||||
|
|
||||||
Open = OggTheora
|
|
||||||
|
|
||||||
|
|
||||||
def delete(filename):
|
|
||||||
"""Remove tags from a file."""
|
|
||||||
|
|
||||||
OggTheora(filename).delete()
|
|
|
@ -1,159 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright 2006 Joe Wreschnig
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""Read and write Ogg Vorbis comments.
|
|
||||||
|
|
||||||
This module handles Vorbis files wrapped in an Ogg bitstream. The
|
|
||||||
first Vorbis stream found is used.
|
|
||||||
|
|
||||||
Read more about Ogg Vorbis at http://vorbis.com/. This module is based
|
|
||||||
on the specification at http://www.xiph.org/vorbis/doc/Vorbis_I_spec.html.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__all__ = ["OggVorbis", "Open", "delete"]
|
|
||||||
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from mutagen import StreamInfo
|
|
||||||
from mutagen._vorbis import VCommentDict
|
|
||||||
from mutagen._util import get_size
|
|
||||||
from mutagen._tags import PaddingInfo
|
|
||||||
from mutagen.ogg import OggPage, OggFileType, error as OggError
|
|
||||||
|
|
||||||
|
|
||||||
class error(OggError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class OggVorbisHeaderError(error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class OggVorbisInfo(StreamInfo):
|
|
||||||
"""Ogg Vorbis stream information."""
|
|
||||||
|
|
||||||
length = 0
|
|
||||||
"""File length in seconds, as a float"""
|
|
||||||
|
|
||||||
channels = 0
|
|
||||||
"""Number of channels"""
|
|
||||||
|
|
||||||
bitrate = 0
|
|
||||||
"""Nominal ('average') bitrate in bits per second, as an int"""
|
|
||||||
|
|
||||||
sample_rate = 0
|
|
||||||
"""Sample rate in Hz"""
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
while not page.packets[0].startswith(b"\x01vorbis"):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
if not page.first:
|
|
||||||
raise OggVorbisHeaderError(
|
|
||||||
"page has ID header, but doesn't start a stream")
|
|
||||||
(self.channels, self.sample_rate, max_bitrate, nominal_bitrate,
|
|
||||||
min_bitrate) = struct.unpack("<B4i", page.packets[0][11:28])
|
|
||||||
self.serial = page.serial
|
|
||||||
|
|
||||||
max_bitrate = max(0, max_bitrate)
|
|
||||||
min_bitrate = max(0, min_bitrate)
|
|
||||||
nominal_bitrate = max(0, nominal_bitrate)
|
|
||||||
|
|
||||||
if nominal_bitrate == 0:
|
|
||||||
self.bitrate = (max_bitrate + min_bitrate) // 2
|
|
||||||
elif max_bitrate and max_bitrate < nominal_bitrate:
|
|
||||||
# If the max bitrate is less than the nominal, we know
|
|
||||||
# the nominal is wrong.
|
|
||||||
self.bitrate = max_bitrate
|
|
||||||
elif min_bitrate > nominal_bitrate:
|
|
||||||
self.bitrate = min_bitrate
|
|
||||||
else:
|
|
||||||
self.bitrate = nominal_bitrate
|
|
||||||
|
|
||||||
def _post_tags(self, fileobj):
|
|
||||||
page = OggPage.find_last(fileobj, self.serial)
|
|
||||||
self.length = page.position / float(self.sample_rate)
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
return u"Ogg Vorbis, %.2f seconds, %d bps" % (
|
|
||||||
self.length, self.bitrate)
|
|
||||||
|
|
||||||
|
|
||||||
class OggVCommentDict(VCommentDict):
|
|
||||||
"""Vorbis comments embedded in an Ogg bitstream."""
|
|
||||||
|
|
||||||
def __init__(self, fileobj, info):
|
|
||||||
pages = []
|
|
||||||
complete = False
|
|
||||||
while not complete:
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
if page.serial == info.serial:
|
|
||||||
pages.append(page)
|
|
||||||
complete = page.complete or (len(page.packets) > 1)
|
|
||||||
data = OggPage.to_packets(pages)[0][7:] # Strip off "\x03vorbis".
|
|
||||||
super(OggVCommentDict, self).__init__(data)
|
|
||||||
self._padding = len(data) - self._size
|
|
||||||
|
|
||||||
def _inject(self, fileobj, padding_func):
|
|
||||||
"""Write tag data into the Vorbis comment packet/page."""
|
|
||||||
|
|
||||||
# Find the old pages in the file; we'll need to remove them,
|
|
||||||
# plus grab any stray setup packet data out of them.
|
|
||||||
fileobj.seek(0)
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
while not page.packets[0].startswith(b"\x03vorbis"):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
|
|
||||||
old_pages = [page]
|
|
||||||
while not (old_pages[-1].complete or len(old_pages[-1].packets) > 1):
|
|
||||||
page = OggPage(fileobj)
|
|
||||||
if page.serial == old_pages[0].serial:
|
|
||||||
old_pages.append(page)
|
|
||||||
|
|
||||||
packets = OggPage.to_packets(old_pages, strict=False)
|
|
||||||
|
|
||||||
content_size = get_size(fileobj) - len(packets[0]) # approx
|
|
||||||
vcomment_data = b"\x03vorbis" + self.write()
|
|
||||||
padding_left = len(packets[0]) - len(vcomment_data)
|
|
||||||
|
|
||||||
info = PaddingInfo(padding_left, content_size)
|
|
||||||
new_padding = info._get_padding(padding_func)
|
|
||||||
|
|
||||||
# Set the new comment packet.
|
|
||||||
packets[0] = vcomment_data + b"\x00" * new_padding
|
|
||||||
|
|
||||||
new_pages = OggPage._from_packets_try_preserve(packets, old_pages)
|
|
||||||
OggPage.replace(fileobj, old_pages, new_pages)
|
|
||||||
|
|
||||||
|
|
||||||
class OggVorbis(OggFileType):
|
|
||||||
"""An Ogg Vorbis file."""
|
|
||||||
|
|
||||||
_Info = OggVorbisInfo
|
|
||||||
_Tags = OggVCommentDict
|
|
||||||
_Error = OggVorbisHeaderError
|
|
||||||
_mimes = ["audio/vorbis", "audio/x-vorbis"]
|
|
||||||
|
|
||||||
info = None
|
|
||||||
"""A `OggVorbisInfo`"""
|
|
||||||
|
|
||||||
tags = None
|
|
||||||
"""A `VCommentDict`"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score(filename, fileobj, header):
|
|
||||||
return (header.startswith(b"OggS") * (b"\x01vorbis" in header))
|
|
||||||
|
|
||||||
|
|
||||||
Open = OggVorbis
|
|
||||||
|
|
||||||
|
|
||||||
def delete(filename):
|
|
||||||
"""Remove tags from a file."""
|
|
||||||
|
|
||||||
OggVorbis(filename).delete()
|
|
|
@ -1,74 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2006 Lukas Lalinsky
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""OptimFROG audio streams with APEv2 tags.
|
|
||||||
|
|
||||||
OptimFROG is a lossless audio compression program. Its main goal is to
|
|
||||||
reduce at maximum the size of audio files, while permitting bit
|
|
||||||
identical restoration for all input. It is similar with the ZIP
|
|
||||||
compression, but it is highly specialized to compress audio data.
|
|
||||||
|
|
||||||
Only versions 4.5 and higher are supported.
|
|
||||||
|
|
||||||
For more information, see http://www.losslessaudio.org/
|
|
||||||
"""
|
|
||||||
|
|
||||||
__all__ = ["OptimFROG", "Open", "delete"]
|
|
||||||
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from ._compat import endswith
|
|
||||||
from mutagen import StreamInfo
|
|
||||||
from mutagen.apev2 import APEv2File, error, delete
|
|
||||||
|
|
||||||
|
|
||||||
class OptimFROGHeaderError(error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class OptimFROGInfo(StreamInfo):
|
|
||||||
"""OptimFROG stream information.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
* channels - number of audio channels
|
|
||||||
* length - file length in seconds, as a float
|
|
||||||
* sample_rate - audio sampling rate in Hz
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
header = fileobj.read(76)
|
|
||||||
if (len(header) != 76 or not header.startswith(b"OFR ") or
|
|
||||||
struct.unpack("<I", header[4:8])[0] not in [12, 15]):
|
|
||||||
raise OptimFROGHeaderError("not an OptimFROG file")
|
|
||||||
(total_samples, total_samples_high, sample_type, self.channels,
|
|
||||||
self.sample_rate) = struct.unpack("<IHBBI", header[8:20])
|
|
||||||
total_samples += total_samples_high << 32
|
|
||||||
self.channels += 1
|
|
||||||
if self.sample_rate:
|
|
||||||
self.length = float(total_samples) / (self.channels *
|
|
||||||
self.sample_rate)
|
|
||||||
else:
|
|
||||||
self.length = 0.0
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
return u"OptimFROG, %.2f seconds, %d Hz" % (self.length,
|
|
||||||
self.sample_rate)
|
|
||||||
|
|
||||||
|
|
||||||
class OptimFROG(APEv2File):
|
|
||||||
_Info = OptimFROGInfo
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score(filename, fileobj, header):
|
|
||||||
filename = filename.lower()
|
|
||||||
|
|
||||||
return (header.startswith(b"OFR") + endswith(filename, b".ofr") +
|
|
||||||
endswith(filename, b".ofs"))
|
|
||||||
|
|
||||||
Open = OptimFROG
|
|
|
@ -1,84 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright (C) 2006 Joe Wreschnig
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of version 2 of the GNU General Public License as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""True Audio audio stream information and tags.
|
|
||||||
|
|
||||||
True Audio is a lossless format designed for real-time encoding and
|
|
||||||
decoding. This module is based on the documentation at
|
|
||||||
http://www.true-audio.com/TTA_Lossless_Audio_Codec\_-_Format_Description
|
|
||||||
|
|
||||||
True Audio files use ID3 tags.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__all__ = ["TrueAudio", "Open", "delete", "EasyTrueAudio"]
|
|
||||||
|
|
||||||
from ._compat import endswith
|
|
||||||
from mutagen import StreamInfo
|
|
||||||
from mutagen.id3 import ID3FileType, delete
|
|
||||||
from mutagen._util import cdata, MutagenError
|
|
||||||
|
|
||||||
|
|
||||||
class error(RuntimeError, MutagenError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class TrueAudioHeaderError(error, IOError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class TrueAudioInfo(StreamInfo):
|
|
||||||
"""True Audio stream information.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
* length - audio length, in seconds
|
|
||||||
* sample_rate - audio sample rate, in Hz
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, fileobj, offset):
|
|
||||||
fileobj.seek(offset or 0)
|
|
||||||
header = fileobj.read(18)
|
|
||||||
if len(header) != 18 or not header.startswith(b"TTA"):
|
|
||||||
raise TrueAudioHeaderError("TTA header not found")
|
|
||||||
self.sample_rate = cdata.int_le(header[10:14])
|
|
||||||
samples = cdata.uint_le(header[14:18])
|
|
||||||
self.length = float(samples) / self.sample_rate
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
return u"True Audio, %.2f seconds, %d Hz." % (
|
|
||||||
self.length, self.sample_rate)
|
|
||||||
|
|
||||||
|
|
||||||
class TrueAudio(ID3FileType):
|
|
||||||
"""A True Audio file.
|
|
||||||
|
|
||||||
:ivar info: :class:`TrueAudioInfo`
|
|
||||||
:ivar tags: :class:`ID3 <mutagen.id3.ID3>`
|
|
||||||
"""
|
|
||||||
|
|
||||||
_Info = TrueAudioInfo
|
|
||||||
_mimes = ["audio/x-tta"]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score(filename, fileobj, header):
|
|
||||||
return (header.startswith(b"ID3") + header.startswith(b"TTA") +
|
|
||||||
endswith(filename.lower(), b".tta") * 2)
|
|
||||||
|
|
||||||
|
|
||||||
Open = TrueAudio
|
|
||||||
|
|
||||||
|
|
||||||
class EasyTrueAudio(TrueAudio):
|
|
||||||
"""Like MP3, but uses EasyID3 for tags.
|
|
||||||
|
|
||||||
:ivar info: :class:`TrueAudioInfo`
|
|
||||||
:ivar tags: :class:`EasyID3 <mutagen.easyid3.EasyID3>`
|
|
||||||
"""
|
|
||||||
|
|
||||||
from mutagen.easyid3 import EasyID3 as ID3
|
|
||||||
ID3 = ID3
|
|
|
@ -1,125 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright 2006 Joe Wreschnig
|
|
||||||
# 2014 Christoph Reiter
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License version 2 as
|
|
||||||
# published by the Free Software Foundation.
|
|
||||||
|
|
||||||
"""WavPack reading and writing.
|
|
||||||
|
|
||||||
WavPack is a lossless format that uses APEv2 tags. Read
|
|
||||||
|
|
||||||
* http://www.wavpack.com/
|
|
||||||
* http://www.wavpack.com/file_format.txt
|
|
||||||
|
|
||||||
for more information.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__all__ = ["WavPack", "Open", "delete"]
|
|
||||||
|
|
||||||
from mutagen import StreamInfo
|
|
||||||
from mutagen.apev2 import APEv2File, error, delete
|
|
||||||
from mutagen._util import cdata
|
|
||||||
|
|
||||||
|
|
||||||
class WavPackHeaderError(error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
RATES = [6000, 8000, 9600, 11025, 12000, 16000, 22050, 24000, 32000, 44100,
|
|
||||||
48000, 64000, 88200, 96000, 192000]
|
|
||||||
|
|
||||||
|
|
||||||
class _WavPackHeader(object):
|
|
||||||
|
|
||||||
def __init__(self, block_size, version, track_no, index_no, total_samples,
|
|
||||||
block_index, block_samples, flags, crc):
|
|
||||||
|
|
||||||
self.block_size = block_size
|
|
||||||
self.version = version
|
|
||||||
self.track_no = track_no
|
|
||||||
self.index_no = index_no
|
|
||||||
self.total_samples = total_samples
|
|
||||||
self.block_index = block_index
|
|
||||||
self.block_samples = block_samples
|
|
||||||
self.flags = flags
|
|
||||||
self.crc = crc
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_fileobj(cls, fileobj):
|
|
||||||
"""A new _WavPackHeader or raises WavPackHeaderError"""
|
|
||||||
|
|
||||||
header = fileobj.read(32)
|
|
||||||
if len(header) != 32 or not header.startswith(b"wvpk"):
|
|
||||||
raise WavPackHeaderError("not a WavPack header: %r" % header)
|
|
||||||
|
|
||||||
block_size = cdata.uint_le(header[4:8])
|
|
||||||
version = cdata.ushort_le(header[8:10])
|
|
||||||
track_no = ord(header[10:11])
|
|
||||||
index_no = ord(header[11:12])
|
|
||||||
samples = cdata.uint_le(header[12:16])
|
|
||||||
if samples == 2 ** 32 - 1:
|
|
||||||
samples = -1
|
|
||||||
block_index = cdata.uint_le(header[16:20])
|
|
||||||
block_samples = cdata.uint_le(header[20:24])
|
|
||||||
flags = cdata.uint_le(header[24:28])
|
|
||||||
crc = cdata.uint_le(header[28:32])
|
|
||||||
|
|
||||||
return _WavPackHeader(block_size, version, track_no, index_no,
|
|
||||||
samples, block_index, block_samples, flags, crc)
|
|
||||||
|
|
||||||
|
|
||||||
class WavPackInfo(StreamInfo):
|
|
||||||
"""WavPack stream information.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
|
|
||||||
* channels - number of audio channels (1 or 2)
|
|
||||||
* length - file length in seconds, as a float
|
|
||||||
* sample_rate - audio sampling rate in Hz
|
|
||||||
* version - WavPack stream version
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, fileobj):
|
|
||||||
try:
|
|
||||||
header = _WavPackHeader.from_fileobj(fileobj)
|
|
||||||
except WavPackHeaderError:
|
|
||||||
raise WavPackHeaderError("not a WavPack file")
|
|
||||||
|
|
||||||
self.version = header.version
|
|
||||||
self.channels = bool(header.flags & 4) or 2
|
|
||||||
self.sample_rate = RATES[(header.flags >> 23) & 0xF]
|
|
||||||
|
|
||||||
if header.total_samples == -1 or header.block_index != 0:
|
|
||||||
# TODO: we could make this faster by using the tag size
|
|
||||||
# and search backwards for the last block, then do
|
|
||||||
# last.block_index + last.block_samples - initial.block_index
|
|
||||||
samples = header.block_samples
|
|
||||||
while 1:
|
|
||||||
fileobj.seek(header.block_size - 32 + 8, 1)
|
|
||||||
try:
|
|
||||||
header = _WavPackHeader.from_fileobj(fileobj)
|
|
||||||
except WavPackHeaderError:
|
|
||||||
break
|
|
||||||
samples += header.block_samples
|
|
||||||
else:
|
|
||||||
samples = header.total_samples
|
|
||||||
|
|
||||||
self.length = float(samples) / self.sample_rate
|
|
||||||
|
|
||||||
def pprint(self):
|
|
||||||
return u"WavPack, %.2f seconds, %d Hz" % (self.length,
|
|
||||||
self.sample_rate)
|
|
||||||
|
|
||||||
|
|
||||||
class WavPack(APEv2File):
|
|
||||||
_Info = WavPackInfo
|
|
||||||
_mimes = ["audio/x-wavpack"]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score(filename, fileobj, header):
|
|
||||||
return header.startswith(b"wvpk") * 2
|
|
||||||
|
|
||||||
|
|
||||||
Open = WavPack
|
|
|
@ -292,18 +292,20 @@ SORT_METHODS_CLIPS = (
|
||||||
SORT_METHODS_MOVIES = (
|
SORT_METHODS_MOVIES = (
|
||||||
'SORT_METHOD_UNSORTED',
|
'SORT_METHOD_UNSORTED',
|
||||||
'SORT_METHOD_TITLE',
|
'SORT_METHOD_TITLE',
|
||||||
'SORT_METHOD_DURATION',
|
'SORT_METHOD_DATEADDED',
|
||||||
|
'SORT_METHOD_GENRE',
|
||||||
'SORT_METHOD_VIDEO_RATING',
|
'SORT_METHOD_VIDEO_RATING',
|
||||||
'SORT_METHOD_VIDEO_USER_RATING',
|
'SORT_METHOD_VIDEO_USER_RATING',
|
||||||
'SORT_METHOD_MPAA_RATING',
|
'SORT_METHOD_MPAA_RATING',
|
||||||
|
'SORT_METHOD_DURATION',
|
||||||
'SORT_METHOD_COUNTRY',
|
'SORT_METHOD_COUNTRY',
|
||||||
'SORT_METHOD_STUDIO',
|
'SORT_METHOD_STUDIO',
|
||||||
'SORT_METHOD_GENRE',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
SORT_METHOD_TVSHOWS = (
|
SORT_METHOD_TVSHOWS = (
|
||||||
'SORT_METHOD_UNSORTED',
|
'SORT_METHOD_UNSORTED',
|
||||||
'SORT_METHOD_TITLE',
|
'SORT_METHOD_TITLE',
|
||||||
|
'SORT_METHOD_DATEADDED',
|
||||||
'SORT_METHOD_VIDEO_RATING',
|
'SORT_METHOD_VIDEO_RATING',
|
||||||
'SORT_METHOD_VIDEO_USER_RATING',
|
'SORT_METHOD_VIDEO_USER_RATING',
|
||||||
'SORT_METHOD_MPAA_RATING',
|
'SORT_METHOD_MPAA_RATING',
|
||||||
|
@ -315,10 +317,11 @@ SORT_METHODS_EPISODES = (
|
||||||
'SORT_METHOD_UNSORTED',
|
'SORT_METHOD_UNSORTED',
|
||||||
'SORT_METHOD_TITLE',
|
'SORT_METHOD_TITLE',
|
||||||
'SORT_METHOD_EPISODE',
|
'SORT_METHOD_EPISODE',
|
||||||
'SORT_METHOD_DURATION',
|
'SORT_METHOD_DATEADDED',
|
||||||
'SORT_METHOD_VIDEO_RATING',
|
'SORT_METHOD_VIDEO_RATING',
|
||||||
'SORT_METHOD_VIDEO_USER_RATING',
|
'SORT_METHOD_VIDEO_USER_RATING',
|
||||||
'SORT_METHOD_MPAA_RATING',
|
'SORT_METHOD_MPAA_RATING',
|
||||||
|
'SORT_METHOD_DURATION',
|
||||||
'SORT_METHOD_FILE',
|
'SORT_METHOD_FILE',
|
||||||
'SORT_METHOD_FULLPATH',
|
'SORT_METHOD_FULLPATH',
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in a new issue