├── .github └── workflows │ └── docker-image.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── config.yaml ├── entrypoint ├── filechange.py ├── main.py ├── requirements.txt ├── strm_to_api.py ├── strm_to_local.py └── test.py /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Library-Strm Builder 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | name: Build Docker Image 12 | steps: 13 | - 14 | name: Checkout 15 | uses: actions/checkout@v3 16 | - 17 | name: Docker meta 18 | id: meta 19 | uses: docker/metadata-action@v3 20 | with: 21 | images: ${{ secrets.DOCKER_USERNAME }}/library-strm 22 | tags: | 23 | type=raw,value=latest 24 | - name: Set Up QEMU 25 | uses: docker/setup-qemu-action@v3 26 | 27 | - name: Set Up Buildx 28 | uses: docker/setup-buildx-action@v3 29 | - 30 | name: Login DockerHub 31 | uses: docker/login-action@v2 32 | with: 33 | username: ${{ secrets.DOCKER_USERNAME }} 34 | password: ${{ secrets.DOCKER_PASSWORD }} 35 | - 36 | name: Build Image 37 | uses: docker/build-push-action@v3 38 | with: 39 | context: . 40 | file: Dockerfile 41 | platforms: | 42 | linux/amd64 43 | linux/arm64/v8 44 | push: true 45 | tags: ${{ steps.meta.outputs.tags }} 46 | labels: ${{ steps.meta.outputs.labels }} 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | *.py[cod] 3 | __pycache__/ 4 | venv/ 5 | env/ 6 | *.egg-info/ 7 | dist/ 8 | build/ 9 | *.egg 10 | *.log 11 | 12 | # IDE/编辑器 13 | .idea/ 14 | .vscode/ 15 | *.sublime-project 16 | *.sublime-workspace 17 | 18 | # 工程依赖 19 | /.venv 20 | /.env 21 | 22 | # 测试相关 23 | htmlcov/ 24 | .coverage 25 | .tox/ 26 | .noseids 27 | nosetests.xml 28 | coverage.xml 29 | 30 | # Jupyter Notebook 31 | .ipynb_checkpoints/ 32 | 33 | # 其他 34 | *.bak 35 | *.swp 36 | *~ 37 | .DS_Store 38 | 39 | .cache 40 | .aligo 41 | alipan_redirect -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | ENV LANG="C.UTF-8" \ 3 | HOME="/alipan_strm" \ 4 | TZ="Asia/Shanghai" \ 5 | PUID=0 \ 6 | PGID=0 \ 7 | UMASK=000 8 | WORKDIR ./alipan_strm 9 | ADD . . 10 | RUN apt-get update \ 11 | && apt-get -y install \ 12 | gosu \ 13 | bash \ 14 | dumb-init \ 15 | && cp -f /alipan_strm/entrypoint /entrypoint \ 16 | && chmod +x /entrypoint \ 17 | && groupadd -r strm -g 911 \ 18 | && useradd -r strm -g strm -d /alipan_strm -s /bin/bash -u 911 \ 19 | && pip install -r requirements.txt 20 | 21 | ENTRYPOINT [ "/entrypoint" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2023 ChenyangGao 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 支持多路径目录监控配置 2 | 3 | [![Github][Github-image]][Github-url] 4 | [![commit activity][commit-activity-image]][commit-activity-url] 5 | [![docker version][docker-version-image]][docker-version-url] 6 | [![docker pulls][docker-pulls-image]][docker-pulls-url] 7 | [![docker stars][docker-stars-image]][docker-stars-url] 8 | [![docker image size][docker-image-size-image]][docker-image-size-url] 9 | [![Platform](https://img.shields.io/badge/platform-amd64/arm64-pink?style=plastic)](https://hub.docker.com/r/thsrite/library-strm) 10 | 11 | [Github-image]: https://img.shields.io/static/v1?label=Github&message=library_strm&color=brightgreen 12 | [Github-url]: https://github.com/thsrite/library_strm 13 | [commit-activity-image]: https://img.shields.io/github/commit-activity/m/thsrite/library_strm 14 | [commit-activity-url]: https://github.com/thsrite/library_strm 15 | [docker-version-image]: https://img.shields.io/docker/v/thsrite/library-strm?style=flat 16 | [docker-version-url]: https://hub.docker.com/r/thsrite/library-strm/tags?page=1&ordering=last_updated 17 | [docker-pulls-image]: https://img.shields.io/docker/pulls/thsrite/library-strm?style=flat 18 | [docker-pulls-url]: https://hub.docker.com/r/thsrite/library-strm 19 | [docker-stars-image]: https://img.shields.io/docker/stars/thsrite/library-strm?style=flat 20 | [docker-stars-url]: https://hub.docker.com/r/thsrite/library-strm 21 | [docker-image-size-image]: https://img.shields.io/docker/image-size/thsrite/library-strm?style=flat 22 | [docker-image-size-url]: https://hub.docker.com/r/thsrite/library-strm 23 | 24 | 25 | ``` 26 | 相关参数 27 | compatibility: fast:性能模式,内部处理系统操作类型选择最优解; compatibility:兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB 28 | source_dir:网盘挂载本地资源路径(监控路径) 29 | dest_dir:strm保存路径(转移路径) 30 | library_dir:媒体库容器内挂载网盘路径(非strm路径!!意味着要把挂载到网盘的本地路径映射到媒体服务器中) 31 | cloud_type:cd2/alist 32 | cloud_path:cd2/alist挂载本地跟路径(不带最后的/) 33 | cloud_url:cd2/alist服务地址(ip:port) 34 | copy_img:True/False 是否开启复制图片 35 | create_strm:True/False 是否开启strm链接,否的的话媒体也会复制 36 | 37 | 支持两种配置方式 38 | 公有字段monitoring_mode、source_dir、dest_dir 39 | 1.本地模式:library_dir 40 | 2.api模式: cloud_type、cloud_path、cloud_url 41 | 42 | 注: 43 | 1.本地模式需要把source_dir挂载到媒体服务器,library_dir为挂载后媒体服务器路径 44 | 2.api模式需要正确填写相关配置,目前只支持cd2/alist两种。 45 | 46 | 优缺点: 47 | 1.本地模式读取快,不需要进过api处理。(实际感觉相差不多) 48 | 2.api模式优势在于:cd2/alist重启后,媒体服务器必须重启才能识别到cd2/alist挂载本地的路径,但是api不需要!!! 49 | ``` 50 | 51 | ``` 52 | sync: 53 | monitor_confs: [ 54 | { 55 | "monitoring_mode": "compatibility", 56 | "source_dir": "/mnt/user/downloads/cloud/aliyun/emby", 57 | "dest_dir": "/mnt/user/downloads/link/aliyun", 58 | "library_dir": "/cloud/aliyun/emby" 59 | }, 60 | { 61 | "monitoring_mode": "compatibility", 62 | "source_dir": "/mnt/user/downloads/cloud/aliyun/emby", 63 | "dest_dir": "/mnt/user/downloads/link/aliyun", 64 | "cloud_type": "cd2", 65 | "cloud_path": "/mnt/user/downloads/cloud", 66 | "cloud_url": "192.168.31.103:19798" 67 | } 68 | ] 69 | 70 | ``` 71 | 72 | ``` 73 | docker run -d --name library_strm \ 74 | -v /mnt/user/downloads/:/mnt/user/downloads/ \ 75 | -v /mnt/config.yaml:/mnt/config.yaml \ 76 | thsrite/library-strm:latest 77 | ``` 78 | 79 | 支持amd、arm 80 | 81 | 首次全量运行需要进容器执行 python test.py 82 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | sync: 2 | monitor_confs: [ 3 | { 4 | "monitoring_mode": "compatibility", 5 | "source_dir": "/mnt/user/downloads/cloud/aliyun/emby", 6 | "dest_dir": "/mnt/user/downloads/link/aliyun", 7 | "library_dir": "/cloud/aliyun/emby", 8 | "cloud_type": "cd2/alist/local", 9 | "cloud_path": "/mnt/user/downloads/cloud", 10 | "cloud_url": "192.168.31.103:19798", 11 | "copy_img": True, 12 | "create_strm": True 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 更改 strm userid 和 groupid 4 | groupmod -o -g ${PGID} strm 5 | usermod -o -u ${PUID} strm 6 | # 更改文件权限 7 | chown -R strm:strm ${HOME} 8 | # 设置后端服务权限掩码 9 | umask ${UMASK} 10 | # 启动后端服务 11 | exec dumb-init gosu strm:strm python3 main.py 12 | -------------------------------------------------------------------------------- /filechange.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import shutil 5 | import urllib.parse 6 | from pathlib import Path 7 | from typing import Any 8 | 9 | import yaml 10 | from watchdog.events import FileSystemEventHandler 11 | from watchdog.observers import Observer 12 | from watchdog.observers.polling import PollingObserver 13 | 14 | logging.basicConfig(filename="alipan_redirect", 15 | format='%(asctime)s - %(name)s - %(levelname)s -%(module)s: %(message)s', 16 | datefmt='%Y-%m-%d %H:%M:%S ', 17 | level=logging.INFO) 18 | logger = logging.getLogger() 19 | KZT = logging.StreamHandler() 20 | KZT.setLevel(logging.DEBUG) 21 | logger.addHandler(KZT) 22 | 23 | 24 | class FileMonitorHandler(FileSystemEventHandler): 25 | """ 26 | 目录监控响应类 27 | """ 28 | 29 | def __init__(self, watching_path: str, file_change: Any, **kwargs): 30 | super(FileMonitorHandler, self).__init__(**kwargs) 31 | self._watch_path = watching_path 32 | self.file_change = file_change 33 | 34 | def on_any_event(self, event): 35 | logger.debug(f"目录监控event_type::: {event.event_type}") 36 | logger.debug(f"目录监控on_any_event事件路径::: {event.src_path}") 37 | 38 | def on_created(self, event): 39 | logger.info(f"目录监控created事件路径::: {event.src_path}") 40 | self.file_change.event_handler(event=event, source_dir=self._watch_path, event_path=event.src_path) 41 | 42 | # def on_deleted(self, event): 43 | # logger.info(f"目录监控deleted事件路径 src_path::: {event.src_path}") 44 | # self.file_change.event_handler(event=event, event_path=event.src_path) 45 | 46 | def on_moved(self, event): 47 | logger.info(f"目录监控moved事件路径 src_path::: {event.src_path}") 48 | logger.info(f"目录监控moved事件路径 dest_path::: {event.dest_path}") 49 | logger.info("fast模式能触发,暂不处理") 50 | # self.file_change.event_handler(event=event, source_dir=self._watch_path, event_path=event.dest_path) 51 | 52 | 53 | class FileChange: 54 | _dirconf = {} 55 | _modeconf = {} 56 | _libraryconf = {} 57 | _cloudtypeconf = {} 58 | _cloudurlconf = {} 59 | _cloudpathconf = {} 60 | _imgconf = {} 61 | _strmconf = {} 62 | 63 | def __init__(self): 64 | """ 65 | 初始化参数 66 | """ 67 | 68 | filepath = os.path.join("/mnt", "config.yaml") 69 | with open(filepath, 'r') as f: # 用with读取文件更好 70 | configs = yaml.load(f, Loader=yaml.FullLoader) # 按字典格式读取并返回 71 | 72 | self.monitor_confs = configs["sync"]["monitor_confs"] 73 | if not isinstance(self.monitor_confs, list): 74 | self.monitor_confs = [self.monitor_confs] 75 | 76 | # 存储目录监控配置 77 | for monitor_conf in self.monitor_confs: 78 | if not isinstance(monitor_conf, dict): 79 | monitor_conf = json.loads(monitor_conf) 80 | self._dirconf[monitor_conf.get("source_dir")] = monitor_conf.get("dest_dir") 81 | self._modeconf[monitor_conf.get("source_dir")] = monitor_conf.get("monitoring_mode") 82 | self._libraryconf[monitor_conf.get("source_dir")] = monitor_conf.get("library_dir") 83 | self._cloudtypeconf[monitor_conf.get("source_dir")] = monitor_conf.get("cloud_type") 84 | self._cloudpathconf[monitor_conf.get("source_dir")] = monitor_conf.get("cloud_path") 85 | self._cloudurlconf[monitor_conf.get("source_dir")] = monitor_conf.get("cloud_url") 86 | self._imgconf[monitor_conf.get("source_dir")] = monitor_conf.get("copy_img", True) 87 | self._strmconf[monitor_conf.get("source_dir")] = monitor_conf.get("create_strm", True) 88 | 89 | def start(self): 90 | """ 91 | 开始目录监控 92 | """ 93 | if not self._dirconf or not self._dirconf.keys(): 94 | logger.error(f"未获取到目录监控配置,请检查配置文件填写是否正确") 95 | 96 | # 遍历 开启多路径目录监控 97 | for source_dir in list(self._dirconf.keys()): 98 | # 转移方式 99 | monitoring_mode = self._modeconf.get(source_dir) 100 | if str(monitoring_mode) == "compatibility": 101 | # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB 102 | observer = PollingObserver(timeout=10) 103 | else: 104 | # 内部处理系统操作类型选择最优解 105 | observer = Observer(timeout=10) 106 | observer.schedule(event_handler=FileMonitorHandler(str(source_dir), self), 107 | path=str(source_dir), 108 | recursive=True) 109 | logger.info(f"开始监控文件夹 {str(source_dir)} 转移方式 {str(monitoring_mode)}") 110 | observer.daemon = True 111 | observer.start() 112 | 113 | def event_handler(self, event, source_dir: str, event_path: str): 114 | """ 115 | 文件变动handler 116 | :param event: 117 | :param source_dir: 118 | :param event_path: 119 | """ 120 | # 回收站及隐藏的文件不处理 121 | if (event_path.find("/@Recycle") != -1 122 | or event_path.find("/#recycle") != -1 123 | or event_path.find("/.") != -1 124 | or event_path.find("/@eaDir") != -1): 125 | logger.info(f"{event_path} 是回收站或隐藏的文件,跳过处理") 126 | return 127 | 128 | # 原盘文件夹不处理 129 | if (event_path.find("/BDMV") != -1 130 | or event_path.find("/CERTIFICATE") != -1): 131 | logger.info(f"{event_path} 是原盘文件夹,跳过处理") 132 | return 133 | logger.info(f"event_type::: {event.event_type}") 134 | 135 | logger.info(f"event_path {event_path} source_path {source_dir}") 136 | if event.event_type == "created": 137 | self.event_handler_created(event, event_path, source_dir) 138 | # if event.event_type == "deleted": 139 | # self.event_handler_deleted(event_path, source_dir) 140 | 141 | def event_handler_created(self, event, event_path: str, source_dir: str): 142 | try: 143 | logger.info(f"event_handler_created event_path:::{event_path}") 144 | # 转移路径 145 | dest_dir = self._dirconf.get(source_dir) 146 | # 媒体库容器内挂载路径 147 | library_dir = self._libraryconf.get(source_dir) 148 | # 云服务类型 149 | cloud_type = self._cloudtypeconf.get(source_dir) 150 | # 云服务挂载本地跟路径 151 | cloud_path = self._cloudpathconf.get(source_dir) 152 | # 云服务地址 153 | cloud_url = self._cloudurlconf.get(source_dir) 154 | # 是否处理图片 155 | img_conf = self._imgconf.get(source_dir) 156 | # 是否创建strm文件 157 | strm_conf = self._strmconf.get(source_dir) 158 | # 文件夹同步创建 159 | if event.is_directory: 160 | target_path = event_path.replace(source_dir, dest_dir) 161 | # 目标文件夹不存在则创建 162 | if not Path(target_path).exists(): 163 | logger.info(f"创建目标文件夹 {target_path}") 164 | os.makedirs(target_path) 165 | else: 166 | # 文件:nfo、图片、视频文件 167 | dest_file = event_path.replace(source_dir, dest_dir) 168 | 169 | # 目标文件夹不存在则创建 170 | if not Path(dest_file).parent.exists(): 171 | logger.info(f"创建目标文件夹 {Path(dest_file).parent}") 172 | os.makedirs(Path(dest_file).parent) 173 | 174 | # 视频文件创建.strm文件 175 | video_formats = ( 176 | '.mp4', '.avi', '.rmvb', '.wmv', '.mov', '.mkv', '.flv', '.ts', '.webm', '.iso', '.mpg') 177 | # 图片文件识别 178 | img_formats = ('.jpg', '.png', '.jpeg', '.bmp', '.gif', '.webp') 179 | # 其他文件识别 180 | nfo_formats = ('.nfo', '.xml', '.txt', '.srt', '.ass', '.sub', '.smi', '.ssa') 181 | if event_path.lower().endswith(video_formats): 182 | if not strm_conf: 183 | print(f"视频strm处理未开,复制视频文件到: {dest_file} ") 184 | shutil.copy2(event_path, dest_file) 185 | # 如果视频文件小于1MB,则直接复制,不创建.strm文件 186 | elif os.path.getsize(event_path) < 1024 * 1024: 187 | shutil.copy2(event_path, dest_file) 188 | logger.info(f"复制视频文件 {event_path} 到 {dest_file}") 189 | else: 190 | # 创建.strm文件 191 | self.__create_strm_file(dest_file=dest_file, 192 | dest_dir=dest_dir, 193 | library_dir=library_dir, 194 | source_file=event_path, 195 | cloud_type=cloud_type, 196 | cloud_path=cloud_path, 197 | cloud_url=cloud_url) 198 | elif event_path.lower().endswith(img_formats): 199 | if not img_conf: 200 | logger.info(f"图片处理未开,跳过处理: {event_path} ") 201 | return 202 | # 图片文件复制 203 | if not os.path.exists(dest_file): 204 | shutil.copy2(event_path, dest_file) 205 | logger.info(f"复制图片文件 {event_path} 到 {dest_file}") 206 | else: 207 | logger.info(f"目标文件 {dest_file} 已存在,跳过处理") 208 | elif event_path.lower().endswith(nfo_formats): 209 | # 元数据、字幕等文件复制 210 | if not os.path.exists(dest_file): 211 | shutil.copy2(event_path, dest_file) 212 | logger.info(f"复制其他文件 {event_path} 到 {dest_file}") 213 | else: 214 | logger.info(f"目标文件 {dest_file} 已存在,跳过处理") 215 | 216 | except Exception as e: 217 | logger.error(f"event_handler_created error: {e}") 218 | print(str(e)) 219 | 220 | def event_handler_deleted(self, event_path: str, source_dir: str): 221 | # 转移路径 222 | dest_dir = self._dirconf.get(source_dir) 223 | deleted_target_path = event_path.replace(source_dir, dest_dir) 224 | 225 | # 只删除不存在的目标路径 226 | if not Path(deleted_target_path).exists(): 227 | logger.info(f"目标路径不存在,跳过删除::: {deleted_target_path}") 228 | else: 229 | logger.info(f"目标路径存在,删除::: {deleted_target_path}") 230 | 231 | if Path(deleted_target_path).is_file(): 232 | Path(deleted_target_path).unlink() 233 | else: 234 | # 非根目录,才删除目录 235 | shutil.rmtree(deleted_target_path) 236 | self.__delete_empty_parent_directory(event_path) 237 | 238 | @staticmethod 239 | def __delete_empty_parent_directory(file_path): 240 | parent_dir = Path(file_path).parent 241 | if ( 242 | parent_dir != Path("/") 243 | and parent_dir.is_dir() 244 | and not any(parent_dir.iterdir()) 245 | and parent_dir.exists() 246 | ): 247 | try: 248 | parent_dir.rmdir() 249 | logger.info(f"删除空的父文件夹: {parent_dir}") 250 | except OSError as e: 251 | logger.error(f"删除空父目录失败: {e}") 252 | 253 | @staticmethod 254 | def __create_strm_file(dest_file: str, dest_dir: str, source_file: str, library_dir: str = None, 255 | cloud_type: str = None, 256 | cloud_path: str = None, cloud_url: str = None): 257 | """ 258 | 生成strm文件 259 | :param library_dir: 260 | :param dest_dir: 261 | :param dest_file: 262 | """ 263 | try: 264 | # 获取视频文件名和目录 265 | video_name = Path(dest_file).name 266 | # 获取视频目录 267 | dest_path = Path(dest_file).parent 268 | 269 | if not dest_path.exists(): 270 | logger.info(f"创建目标文件夹 {dest_path}") 271 | os.makedirs(str(dest_path)) 272 | 273 | # 构造.strm文件路径 274 | strm_path = os.path.join(dest_path, f"{os.path.splitext(video_name)[0]}.strm") 275 | logger.info(f"替换前本地路径:::{dest_file}") 276 | 277 | # 云盘模式 278 | if cloud_type: 279 | # 替换路径中的\为/ 280 | dest_file = source_file.replace("\\", "/") 281 | dest_file = dest_file.replace(cloud_path, "") 282 | # 对盘符之后的所有内容进行url转码 283 | dest_file = urllib.parse.quote(dest_file, safe='') 284 | if str(cloud_type) == "cd2": 285 | # 将路径的开头盘符"/mnt/user/downloads"替换为"http://localhost:19798/static/http/localhost:19798/False/" 286 | dest_file = f"http://{cloud_url}/static/http/{cloud_url}/False/{dest_file}" 287 | logger.info(f"替换后cd2路径:::{dest_file}") 288 | elif str(cloud_type) == "alist": 289 | dest_file = f"http://{cloud_url}/d/{dest_file}" 290 | logger.info(f"替换后alist路径:::{dest_file}") 291 | else: 292 | logger.error(f"云盘类型 {cloud_type} 错误") 293 | return 294 | else: 295 | # 本地挂载路径转为emby路径 296 | dest_file = dest_file.replace(dest_dir, library_dir) 297 | logger.info(f"替换后emby容器内路径:::{dest_file}") 298 | 299 | # 写入.strm文件 300 | with open(strm_path, 'w') as f: 301 | f.write(dest_file) 302 | 303 | logger.info(f"创建strm文件 {strm_path}") 304 | except Exception as e: 305 | logger.error(f"创建strm文件失败") 306 | print(str(e)) 307 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | 3 | from fastapi import FastAPI 4 | import uvicorn as uvicorn 5 | from uvicorn import Config 6 | 7 | import filechange 8 | 9 | if __name__ == '__main__': 10 | # 文件监控 11 | filechange.FileChange().start() 12 | # App 13 | App = FastAPI(title='library_strm', 14 | openapi_url="/api/v1/openapi.json") 15 | 16 | # uvicorn服务 17 | Server = uvicorn.Server(Config(App, host='0.0.0.0', port=33455, 18 | reload=False, workers=multiprocessing.cpu_count())) 19 | # 启动服务 20 | Server.run() 21 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyYAML==6.0 2 | APScheduler~=3.10.1 3 | uvicorn~=0.22.0 4 | fastapi~=0.96.0 5 | watchdog~=3.0.0 -------------------------------------------------------------------------------- /strm_to_api.py: -------------------------------------------------------------------------------- 1 | import re 2 | import urllib.parse 3 | from pathlib import Path 4 | from typing import List 5 | 6 | 7 | def list_files(directory: Path, extensions: list, min_filesize: int = 0) -> List[Path]: 8 | """ 9 | 获取目录下所有指定扩展名的文件(包括子目录) 10 | """ 11 | 12 | if not min_filesize: 13 | min_filesize = 0 14 | 15 | if not directory.exists(): 16 | return [] 17 | 18 | if directory.is_file(): 19 | return [directory] 20 | 21 | if not min_filesize: 22 | min_filesize = 0 23 | 24 | files = [] 25 | pattern = r".*(" + "|".join(extensions) + ")$" 26 | 27 | # 遍历目录及子目录 28 | for path in directory.rglob('**/*'): 29 | if path.is_file() \ 30 | and re.match(pattern, path.name, re.IGNORECASE) \ 31 | and path.stat().st_size >= min_filesize * 1024 * 1024: 32 | files.append(path) 33 | 34 | return files 35 | 36 | # nas上strm视频根路径 /mnt/user/downloads/link/aliyun/tvshow/爸爸去哪儿/Season 5/14.特别版.strm 37 | source_path = "/mnt/user/downloads/link/aliyun" 38 | # 云盘源文件挂载本地后 挂载进媒体服务器的根路径,与上方对应 /aliyun/emby/tvshow/爸爸去哪儿/Season 5/14.特别版.mp4 39 | library_path = "/aliyun/emby" 40 | 41 | files = list_files(Path(source_path), ['.strm']) 42 | 43 | for f in files: 44 | print(f"开始处理文件 {f}") 45 | try: 46 | library_file = str(f).replace(source_path, library_path) 47 | # 对盘符之后的所有内容进行url转码 48 | library_file = urllib.parse.quote(library_file, safe='') 49 | 50 | # 将路径的开头盘符"/mnt/user/downloads"替换为"http://localhost:19798/static/http/localhost:19798/False/" 51 | # http://192.168.31.103:19798/static/http/192.168.31.103:19798/False/%2F115%2Femby%2Fanime%2F%20%E4%B8%83%E9%BE%99%E7%8F%A0%20%281986%29%2FSeason%201.%E5%9B%BD%E8%AF%AD%2F%E4%B8%83%E9%BE%99%E7%8F%A0%20-%20S01E002%20-%201080p%20AAC%20h264.mp4 52 | api_file = f"http://192.168.31.103:19798/static/http/192.168.31.103:19798/False/{library_file}" 53 | with open(f, 'w') as file2: 54 | print(f"开始写入 api路径 {api_file}") 55 | file2.write(str(api_file)) 56 | except Exception as e: 57 | print(e) 58 | -------------------------------------------------------------------------------- /strm_to_local.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | from typing import List 4 | 5 | 6 | def list_files(directory: Path, extensions: list, min_filesize: int = 0) -> List[Path]: 7 | """ 8 | 获取目录下所有指定扩展名的文件(包括子目录) 9 | """ 10 | 11 | if not min_filesize: 12 | min_filesize = 0 13 | 14 | if not directory.exists(): 15 | return [] 16 | 17 | if directory.is_file(): 18 | return [directory] 19 | 20 | if not min_filesize: 21 | min_filesize = 0 22 | 23 | files = [] 24 | pattern = r".*(" + "|".join(extensions) + ")$" 25 | 26 | # 遍历目录及子目录 27 | for path in directory.rglob('**/*'): 28 | if path.is_file() \ 29 | and re.match(pattern, path.name, re.IGNORECASE) \ 30 | and path.stat().st_size >= min_filesize * 1024 * 1024: 31 | files.append(path) 32 | 33 | return files 34 | 35 | # nas上strm视频根路径 /mnt/user/downloads/link/aliyun/tvshow/爸爸去哪儿/Season 5/14.特别版.strm 36 | source_path = "/mnt/user/downloads/link/aliyun" 37 | # 云盘源文件挂载本地后 挂载进媒体服务器的路径,与上方对应 /mount/cloud/aliyun/emby/tvshow/爸爸去哪儿/Season 5/14.特别版.mp4 38 | library_path = "/mount/cloud/aliyun/emby" 39 | 40 | files = list_files(Path(source_path), ['.strm']) 41 | 42 | for f in files: 43 | print(f"开始处理文件 {f}") 44 | try: 45 | with open(f, 'r') as file: 46 | content = file.read() 47 | # 获取扩展名 48 | ext = str(content).split(".")[-1] 49 | library_file = str(f).replace(source_path, library_path) 50 | library_file = Path(library_file).parent.joinpath(Path(library_file).stem + "." + ext) 51 | with open(f, 'w') as file2: 52 | print(f"开始写入 媒体库路径 {library_file}") 53 | file2.write(str(library_file)) 54 | except Exception as e: 55 | print(e) 56 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import shutil 4 | import urllib.parse 5 | 6 | import yaml 7 | import logging 8 | 9 | from pathlib import Path 10 | 11 | logger = logging.getLogger() 12 | 13 | 14 | def create_strm_file(dest_file, dest_dir, source_file, library_dir, cloud_type=None, cloud_path=None, cloud_url=None): 15 | """ 16 | 生成strm文件 17 | :param dest_file: 18 | :param dest_dir: 19 | :param library_dir: 20 | :return: 21 | """ 22 | try: 23 | # 获取视频文件名和目录 24 | video_name = Path(dest_file).name 25 | 26 | dest_path = Path(dest_file).parent 27 | 28 | # 构造.strm文件路径 29 | strm_path = os.path.join(dest_path, f"{os.path.splitext(video_name)[0]}.strm") 30 | logger.info(f"替换前本地路径:::{dest_file}") 31 | 32 | if os.path.exists(strm_path): 33 | print(f"strm文件已存在,跳过处理::: {strm_path}") 34 | return 35 | 36 | # 云盘模式 37 | if cloud_type: 38 | # 替换路径中的\为/ 39 | dest_file = source_file.replace("\\", "/") 40 | dest_file = dest_file.replace(cloud_path, "") 41 | # 对盘符之后的所有内容进行url转码 42 | dest_file = urllib.parse.quote(dest_file, safe='') 43 | if str(cloud_type) == "cd2": 44 | # 将路径的开头盘符"/mnt/user/downloads"替换为"http://localhost:19798/static/http/localhost:19798/False/" 45 | dest_file = f"http://{cloud_url}/static/http/{cloud_url}/False/{dest_file}" 46 | logger.info(f"替换后cd2路径:::{dest_file}") 47 | elif str(cloud_type) == "alist": 48 | dest_file = f"http://{cloud_url}/d/{dest_file}" 49 | logger.info(f"替换后alist路径:::{dest_file}") 50 | else: 51 | logger.error(f"云盘类型 {cloud_type} 错误") 52 | return 53 | else: 54 | # 本地挂载路径转为emby路径 55 | dest_file = dest_file.replace(dest_dir, library_dir) 56 | logger.info(f"替换后emby容器内路径:::{dest_file}") 57 | 58 | print(f"video_name 文件名字::: {video_name}") 59 | print(f"dest_path parent 文件目录::: {dest_path}") 60 | print(f"strm_path strm路径::: {strm_path}") 61 | print(f"emby_play_path emby播放地址::: {dest_file}") 62 | 63 | # 写入.strm文件 64 | with open(strm_path, "w") as f: 65 | f.write(dest_file) 66 | 67 | print(f"已写入 {strm_path}::: {dest_file}") 68 | except Exception as e: 69 | print(str(e)) 70 | 71 | 72 | def copy_files(source_dir, dest_dir, library_dir, cloud_type=None, cloud_path=None, cloud_url=None, img_conf=True, strm_conf=True): 73 | if not os.path.exists(dest_dir): 74 | os.makedirs(dest_dir) 75 | video_formats = ('.mp4', '.avi', '.rmvb', '.wmv', '.mov', '.mkv', '.flv', '.ts', '.webm', '.iso', '.mpg', '.m2ts') 76 | # 图片文件识别 77 | img_formats = ('.jpg', '.png', '.jpeg', '.bmp', '.gif', '.webp') 78 | # 其他文件识别 79 | nfo_formats = ('.nfo', '.xml', '.txt', '.srt', '.ass', '.sub', '.smi', '.ssa') 80 | for root, dirs, files in os.walk(source_dir): 81 | # 如果遇到名为'extrafanart'的文件夹,则跳过处理该文件夹,继续处理其他文件夹 82 | if "extrafanart" in dirs: 83 | dirs.remove("extrafanart") 84 | if "BDMV" in dirs: 85 | print(f"源文件夹是原盘文件夹,跳过处理::: {source_dir}") 86 | dirs.remove("BDMV") 87 | if "CERTIFICATE" in dirs: 88 | print(f"源文件夹是原盘文件夹,跳过处理::: {source_dir}") 89 | dirs.remove("CERTIFICATE") 90 | 91 | for file in files: 92 | try: 93 | source_file = os.path.join(root, file) 94 | print(f"处理源文件::: {source_file}") 95 | 96 | dest_file = os.path.join(dest_dir, os.path.relpath(source_file, source_dir)) 97 | print(f"开始生成目标文件::: {dest_file}") 98 | 99 | # 创建目标目录中缺少的文件夹 100 | if not os.path.exists(Path(dest_file).parent): 101 | os.makedirs(Path(dest_file).parent) 102 | 103 | # 如果目标文件已存在,跳过处理 104 | if os.path.exists(dest_file): 105 | print(f"文件已存在,跳过处理::: {dest_file}") 106 | continue 107 | 108 | if file.lower().endswith(video_formats): 109 | if not strm_conf: 110 | print(f"视频strm处理未开,复制视频文件到: {dest_file} ") 111 | shutil.copy2(source_file, dest_file) 112 | # 如果视频文件小于1MB,则直接复制,不创建.strm文件 113 | elif os.path.getsize(source_file) < 1024 * 1024 : 114 | print(f"视频文件小于1MB的视频文件到:::{dest_file}") 115 | shutil.copy2(source_file, dest_file) 116 | else: 117 | # 创建.strm文件 118 | create_strm_file(dest_file=dest_file, 119 | dest_dir=dest_dir, 120 | source_file=source_file, 121 | library_dir=library_dir, 122 | cloud_type=cloud_type, 123 | cloud_path=cloud_path, 124 | cloud_url=cloud_url) 125 | elif file.lower().endswith(img_formats): 126 | if not img_conf: 127 | print(f"图片处理未开,跳过处理: {source_file} ") 128 | return 129 | # 图片文件复制 130 | shutil.copy2(source_file, dest_file) 131 | print(f"复制图片文件 {source_file} 到 {dest_file}") 132 | elif file.lower().endswith(nfo_formats): 133 | # 元数据、字幕等文件复制 134 | shutil.copy2(source_file, dest_file) 135 | print(f"复制其他文件 {source_file} 到 {dest_file}") 136 | except Exception as e: 137 | logger.error(f"copy_files error: {e}") 138 | print(str(e)) 139 | 140 | filepath = os.path.join("/mnt", "config.yaml") 141 | 142 | with open(filepath, "r") as f: # 用with读取文件更好 143 | configs = yaml.load(f, Loader=yaml.FullLoader) # 按字典格式读取并返回 144 | 145 | monitor_confs = configs["sync"]["monitor_confs"] 146 | if not isinstance(monitor_confs, list): 147 | monitor_confs = [monitor_confs] 148 | # 存储目录监控配置 149 | for monitor_conf in monitor_confs: 150 | if not isinstance(monitor_conf, dict): 151 | monitor_conf = json.loads(monitor_conf) 152 | source_dir = monitor_conf.get("source_dir") 153 | dest_dir = monitor_conf.get("dest_dir") 154 | library_dir = monitor_conf.get("library_dir") 155 | cloud_type = monitor_conf.get("cloud_type") 156 | cloud_path = monitor_conf.get("cloud_path") 157 | cloud_url = monitor_conf.get("cloud_url") 158 | img_conf = monitor_conf.get("copy_img") 159 | strm_conf = monitor_conf.get("create_strm") 160 | 161 | print(f"source::: {source_dir}") 162 | print(f"dest_dir::: {dest_dir}") 163 | print(f"library_dir::: {library_dir}") 164 | 165 | print(f"开始初始化处理文件 {source_dir}") 166 | 167 | # 批量生成strm文件 168 | copy_files(source_dir, dest_dir, library_dir, cloud_type=cloud_type, cloud_path=cloud_path, cloud_url=cloud_url, img_conf=img_conf, strm_conf=strm_conf) 169 | 170 | print(f"{source_dir} 初始化处理文件完成") 171 | --------------------------------------------------------------------------------