目录 目录 讲故事 安装配置 Nextcloud 安装配置 Obsidian 讲故事 和朋友聊天意识到，每天有太多经历和想法在记忆中稍纵即逝，不留下记录的话以后就完全回忆不起来了，所以需要写日记。 我写字不好看也不快，比较习惯打字，所以记电子版的日记。 我希望日记使用 Markdown 格式，能够全平台多端同步，使用开源软件和开放协议。 以前用过一段时间的为知笔记，客户端算不上好用，导出也不方便，服务端 docker 部署但是不开源（好像），同步也是私有协议。当时可能没发现更好的就用了这个，在四个月里面断断续续记了不少，后来没坚持下去。而且为知笔记的服务端总是在服务器后台占用 10% 左右还是多点的 CPU，感觉有点不对劲。 这段时间因为和朋友聊得很多，每天也有很多想法，所以重新开始寻找日记的解决方案。 在对比了很多选择之后，选择了 Obsidian + Remotely Save 插件使用 Webdav 协议与 Nextcloud 同步的方案。 安装配置 Nextcloud 注：以下内容只是一个简单的过程记录，没有详细步骤，而且功能只为满足日记同步需求 准备一个 Linux 服务器，安装 Docker 和 Nginx docker-compose.yaml： services: nextcloud: container_name: nextcloud-aio-mastercontainer image: ghcr.io/nextcloud-releases/all-in-one:latest init: true restart: always ports: - 8080:8080 environment: - APACHE_PORT=8081 - APACHE_IP_BINDING=127.0.0.1 - NEXTCLOUD_DATADIR=/data/ncdata - SKIP_DOMAIN_VALIDATION=false volumes: - nextcloud_aio_mastercontainer:/mnt/docker-aio-config - /var/run/docker.sock:/var/run/docker.sock:ro network_mode: bridge volumes: nextcloud_aio_mastercontainer: name: nextcloud_aio_mastercontainer 然后打开 ip:8080 进行 Nextcloud AIO 的配置 按照说明安装好 Nextcloud 之后在 Nginx 配置反向代理，代理地址为 localhost:8081 访问反代到的域名，使用 Nextcloud AIO 面板提供的管理员账号密码登录 Nextcloud 设置很多，可以随便翻翻改改 在右上角菜单打开账号页面，为 Obsidian 同步添加一个账号 登录新的账号，在右上角菜单打开个人设置页面，在左边切换到安全标签 输入一个应用名称（比如“Obsidian”），点创建新应用，这样会生成一个应用密钥，妥善保存 Nextcloud 就配置完成了 安装配置 Obsidian 在 Obsidian 官网 下载安装 Obsidian 打开 Obsidian，创建一个新的仓库，为它取一个名字，选择保存的目录 在仓库目录下创建三个文件夹 Attachments Daily Templates 在 Templates 文件夹中新建一个 Daily.md，输入以下内容（可以是任意适合你自己的日记模板）： # {{date:YYYY-MM-DD}} {{date:dddd}} ## 今日待办 - [ ] ## 随手记录 - ## 心情 - ## 附件 / 链接 打开设置，在 第三方插件 中关闭安全模式，在社区插件市场安装并启用这两个插件：Periodic Notes Remotely Save，其他插件看个人喜好，核心插件也可以选择性地开一些关一些 设置 - 文件与链接 - 附件默认存放路径 改成 指定的附件文件夹，附件文件夹路径 选择 Attachments 启用 设置 - Periodic Notes - Daily Notes，Daily Notes Template 选择 Templates/Daily.md，Note Folder 选择 Daily 设置 - Remotely Save - 选择远程服务 选择 Webdav，服务器地址 填 https://<你的Nextcloud域名>/remote.php/dav/files/<同步用户名>/Obsidian，用户名填刚设置的同步用户名，密码填刚生成的应用密码（不要用用户主密码！） depth header 选支持 infinity，自动运行、启动后自动运行一次、保存时同步我是都开了 然后关掉设置，点击窗口最左边一列图标中的 Open today，开始写日记吧！ 各种平台的应用都按相同方式配置一遍就能互相同步啦~
第一次考雅思，考前准备大约是： 扇贝1800词的词库背了一半多不想背了（设置的每天200新词600复习真受不了） 雅思哥听力阅读做了几套熟悉一下 考前作文口语来不及学了在B站刷了几个十分钟视频 就这些了所以我感觉算半裸考🥺 考前目标是6.5-7分 上午先考的口语感觉很完蛋磕磕绊绊全是小学生句子，感觉5-6分，最后6分也算是考官很善良了，主要是最近几个月几乎没和人说过话，中文都说不利索更不用说英文了 下午听力考的时候感觉比较轻松，感觉有希望满分（因为考前用1.4倍速音频磨了磨耳朵感觉能完全听懂），最后8.5也不知道是错了哪里 阅读有几个没搞清楚到底是f还是ng，这两个我总是会纠结，只能说出题人的意图还没捉摸透，估计多做做或者看过有人总结规律的话也许就能8.5变9分了 作文一直是我的弱项，高考英语分全扣在作文，四六级作文分也比另外两部分低好几十，毕竟表达能力又差又不肯花工夫去记好词好句模板是这样的，在考场里也是对着题目想了半天，最后很赶时间写完的，6.5也算是符合预期了 考后三天14:00（应该是）出的成绩，不知道算快还是慢，7.5还是蛮惊喜的，认为不用再刷分了 但其实我离毕业还有两年，如果申的学校要求入学时候成绩有效的话还要再考一次🤣准备倒是不一定要再花时间，但现在学校只给报销一次雅思报名费（没有说不合理的意思），再考一次就要自己掏两千多块钱了 6.15 发小红书的
从宝塔面板迁移网站到 1Panel 的注意事项
目录 目录 简单部分 特别的注意点 简单部分 一般的操作自己摸一下就懂了，主要是把网站文件和数据库备份了然后导入过来，可能有一些特别的配置需要手动配置一下。 然后就是配置域名和证书，操作都差不多，不会网上随便搜个教程就行。 特别的注意点 网站根目录的 user.ini 记得删了，不然访问不了。 数据库访问地址不再是 localhost 了，而是数据库容器的名字，可以在 数据库 页面的 连接信息 查看。
目录 目录 DERP 介绍 服务架构 部署 Tailscale Docker Nginx 访问控制 使用 DERP 介绍 DERP (Designated Encrypted Relay for Packets) servers manage device connections and NAT traversal. They serve two primary purposes: negotiating direct connections between tailnet devices and serving as a relay server when a direct connection isn’t possible. Most connections between tailnet devices only use DERP servers to establish a direct connection to another tailnet device. But as a last resort, when a direct connection isn’t possible due to hard NAT, firewalls, or another reason, devices can communicate using a DERP server as a relay. DERP servers are dual-stack, meaning they support IPv4 and IPv6. As a result, they can facilitate connections between IPv4-only and IPv6-only devices. Data sent between devices using a DERP relayed connection is encrypted using WireGuard. Because Tailscale private keys never leave the device where they were generated, it’s impossible for a DERP server to decrypt your traffic. A DERP server blindly forwards already-encrypted traffic from one device to another. derp-servers Some especially cruel networks block UDP entirely, or are otherwise so strict that they simply cannot be traversed using STUN and ICE. For those situations, Tailscale provides a network of so-called DERP (Designated Encrypted Relay for Packets) servers. These fill the same role as TURN servers in the ICE standard, except they use HTTPS streams and WireGuard keys instead of the obsolete TURN recommendations. encrypted-tcp-relays-derp 简而言之呢就是为 Tailscale 设计的 TCP 中继服务。 服务架构 graph TD subgraph Server TailscaleClient(Tailscale Client) Docker(Docker) subgraph Docker DERPServer(DERP Server) end Nginx("Nginx reverse proxy") end subgraph Tailnet Client1(Client 1) Client2(Client 2) ClientN(Client n) end TailscaleClient <-- tailscale.sock --> DERPServer TailscaleClient <-- Tailscale --> Tailnet DERPServer <-- 8443:8443/tcp --> Nginx DERPServer <-- 3478:3478/udp --> Tailnet Nginx <-- 443/tcp --> Tailnet 部署 Tailscale 使用以下命令安装 Tailscale： curl -fsSL https://tailscale.com/install.sh | sh 然后使用 tailscale up 命令启动并登录账号。 Docker 创建上述内容的 docker-compose.yml，把 example.com 替换成你的域名，然后使用 docker compose up -d 启动服务。 services: derp: image: fredliang/derper:latest container_name: derp restart: always ports: - "8443:8443" - "3478:3478/udp" environment: DERP_DOMAIN: example.com DERP_ADDR: :8443 DERP_VERIFY_CLIENTS: true DERP_CERT_MODE: letsencrypt DERP_CERT_DIR: /app/certs DERP_STUN: true volumes: - /usr/share/zoneinfo/Asia/Shanghai:/etc/localtime:ro - /var/run/tailscale/tailscaled.sock:/var/run/tailscale/tailscaled.sock Nginx 使用 Nginx 反向代理 derp 的 http 端口，域名和证书自己准备。 server { listen 443 ssl http2; server_name example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; location / { proxy_redirect off; proxy_pass http://127.0.0.1:8443; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } 访问控制 访问 Tailscale 管理面板的 Access Control 页面，编辑以下内容： { "derpMap": { "OmitDefaultRegions": true, "Regions": { "114": { "RegionID": 114, "RegionCode": "exp", "RegionName": "Example", "Nodes": [ { "Name": "example", "RegionID": 114, "HostName": "example.com", "DERPPort": 443, }, ], }, }, }, "acls": [ ...... ], ... 其他原有内容 ... } "OmitDefaultRegions": true 意为不使用官方提供的公共 DERP 服务器，因为都在境外，一般延迟较高，但也可以选择选择 false 来保留备用。 编号、名称自己随便取，域名和端口和上面的 Nginx 配置一致。如果保留了公共 derp 服务器，注意自己服务器的各项信息不要和原有的冲突。 使用 在客户端安装并登录 Tailscale，在客户端的命令行输入 tailscale netcheck，如果能看到 DERP 服务器的信息，说明部署成功。
目录 目录 讲故事 为什么选择 Immich 安装前准备 安装 Docker 编写 docker-compose.yml 编写 .env 下载 hwaccel.ml.yml 安装 启动 Immich 访问 Immich 设置 Immich 设置移动设备自动备份 自用懒人小脚本 start.bat update.bat 结语 讲故事 电脑上有特别多照片，大概几个 T，之前就简单地存在硬盘里，用文件夹整理了一下。照片看肯定是看不过来的，想找也不知道要怎么找。 起初试了很多 Windows 本地安装的照片管理软件，感觉没啥好的。我想着至少能找到苹果相册那样水平的软件，但可惜没有。 最后选中了两个支持人脸识别分类的软件：Lightroom 和 Picasa。 Lightroom 众所周知是个庞大的商业软件，启动时间很长，处理大量照片很卡。它虽然有人脸识别分类功能，但毕竟不是以相册为目的设计的，不适合作为一个相册来使用。 最后在选择有限的情况下，最好的选择就是 Picasa。它是 Google 在很久以前推出的免费相册软件，支持人脸识别分类，启动很快，使用流畅。它会生成预览图保存到数据库，从而使得快速翻阅照片的时候基本上不用等待图片加载。它的人脸相册整理方便流畅，还能使用包含人脸的照片生成回忆影片。虽然但是，它在很多年前就停止支持了。它曾经能够使用的照片上传云服务转变为了现在的 Google Photos，软件也不再更新。它在如今的 Windows 11 上仍能正常使用，唯一缺少的就是 hidpi 适配。 人脸识别相册的算法还是能经常给人惊喜的，偶尔会突然发现它识别出来一张特别模糊或者角度很偏的人脸，但分类居然还是对的。 扯得有点远了，反正我就很开心地用了蛮久 Picasa，直到有一天。那天我觉得 Picasa 虽然能满足我人脸识别分类的需求，但并没有多少别的整理功能。如果我想根据时间或者内容或者地点查找照片，还是需要自己手动翻阅一大堆照片，配合记忆来搜索。它作为一个原生 win32 软件，只能在电脑上本地运行，离开了电脑没法看到照片。它也不能帮助我保护照片安全，备份频率决定了意外发生时我会丢多少照片，我仍旧需要每隔一段时间自己把手机、iPad 等设备上的照片手动导入到电脑，然后启动 Picasa 让它扫描。 为什么选择 Immich 于是我决定搭建一个照片服务器。根据多方信息进行对比，我认为用户评价较好、完成度较高的照片服务器应用有以下几款： Immich：开源免费，捐赠与否不影响功能。支持人脸识别、自然语言搜索、地图相册等，功能比较完善。有多种开源 clip 模型和人脸识别模型可以选择，实测搜索效果非常好，并且支持组合筛选。有多平台客户端，移动设备后台自动备份，实测工作稳定，反应速度比系统自带云备份还快。支持为各种格式的照片和视频生成预览，可选高压缩率的 webp 等格式，占用空间不大。主要的视图是时间轴，浏览原始目录层级的文件夹模式刚刚推出，还不完善。没有 OCR 搜索功能，这个我蛮想要的，目前还没见到哪家有，希望以后能加上。 PhotoPrism：开源，基础功能免费，少量高级功能需要订阅，个人用途一般免费够用。支持人脸识别、地图相册、文件夹视图等，功能比较完善。人脸识别效果一般，文档里说它用的模型没多少亚洲人和小孩的训练数据，怪不得很多都识别不出来，而且模型不能换，光这点就可以不用选了。搜图方面，感觉它只用了很简单的图像标签模型，不能自然语言搜索，只有几个常见的关键词能搜到图片，同样不能换模型。文件夹视图比 Immich 完善。预览图非常占空间，每张图片生成了十张预览图和缩略图，在我电脑上占了一百多 g，看了一下它的预览图分辨率，最大尺寸和最大尺寸小一点点各生成了一张，不知道在想什么。 Nextcloud Photos：Nextcloud 的一个应用 （插件），根据网上对比在各种功能方面完成度也是很高的，但我感觉 Nextcloud 已经非常臃肿，网页用起来都卡卡的，实在算不上流畅（不过也有我云服务器性能不行的关系），就不装这个给它增加负担了。不过确实是可以在自己的照片服务器单独装一个 Nextcloud，这样性能方面应该问题不大，但和我云服务器上的 Nextcloud 就要冲突了。（好像 Nextcloud 客户端都是可以添加多个服务器的？） MT Photos：订阅制，有永久版，价格不贵。支持人脸识别、自然语言搜索、地图相册等，功能该有的基本上有。它居然支持 OCR 搜索，刚说没人有这下打脸了，希望 Immich 能跟进。它用的 clip 和人脸识别模型都比 Immich 用的差一些，可能是考虑到要兼容各种配置的设备，毕竟它能配置的选项确实蛮少的，算是傻瓜式应用。UI 和 Immich 就是一个模子刻出来的，因为都是照着 Google Photos 做的。国内平台蛮多人推荐这个，确实好用不贵而且用起来很简单。但我个人喜欢有免费的就用免费的，而且主观上会给开源加分，倾向于能折腾的东西，所以觉得它不如 Immich。 Synology Photos：顾名思义，群晖用的。我没有群晖 NAS，也没听说过黑什么的，于是没有用过。听说它的功能性也挺不错的，但反正有不限制群晖设备的替代品，就不用考虑了。 最后显然，Immich 在我这里胜出了。 安装前准备 安装 Docker 装系统就不讲了啊 我使用的是 Windows 台式机，安装 Docker for Desktop，它会安装 WSL 并把 Docker 安装到 WSL 中，还带有一个便于操作的图形化管理器。 Linux 的话自己看官方文档吧 编写 docker-compose.yml 官方 docker-compose.yml 模板下载地址：https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml 以下是我的 docker-compose.yml： name: immich services: immich-server: container_name: immich_server image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} # extends: # file: hwaccel.transcoding.yml # service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding volumes: - ${UPLOAD_LOCATION}/encoded-video:/usr/src/app/upload/encoded-video - ${UPLOAD_LOCATION}/library:/usr/src/app/upload/library - ${UPLOAD_LOCATION}/thumbs:/usr/src/app/upload/thumbs - /etc/localtime:/etc/localtime:ro - "C:/Users/lsy22/Pictures:/immich-photo-library/Pictures:ro" - "D:/图&片:/immich-photo-library/图&片:ro" - "D:/Backup/ImmichPhotoBackup:/usr/src/app/upload/upload" env_file: - .env ports: - 2283:2283 depends_on: - redis - database restart: always immich-machine-learning: container_name: immich_machine_learning # For hardware acceleration, add one of -[armnn, cuda, openvino] to the image tag. # Example tag: ${IMMICH_VERSION:-release}-cuda image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}-cuda extends: # uncomment this section for hardware acceleration - see https://immich.app/docs/features/ml-hardware-acceleration file: hwaccel.ml.yml service: cuda # set to one of [armnn, cuda, openvino, openvino-wsl] for accelerated inference - use the `-wsl` version for WSL2 where applicable volumes: - ./model-cache:/cache env_file: - .env restart: always healthcheck: disable: false redis: container_name: immich_redis image: docker.io/redis:6.2-alpine@sha256:2d1463258f2764328496376f5d965f20c6a67f66ea2b06dc42af351f75248792 healthcheck: test: redis-cli ping || exit 1 restart: always database: container_name: immich_postgres image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 environment: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_USER: ${DB_USERNAME} POSTGRES_DB: ${DB_DATABASE_NAME} POSTGRES_INITDB_ARGS: '--data-checksums' volumes: - "database:/var/lib/postgresql/data" healthcheck: test: pg_isready --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1 interval: 5m start_interval: 30s start_period: 5m command: ["postgres", "-c" ,"shared_preload_libraries=vectors.so", "-c", 'search_path="$$user", public, vectors', "-c", "logging_collector=on", "-c", "max_wal_size=2GB", "-c", "shared_buffers=512MB", "-c", "wal_compression=on"] restart: always volumes: database: driver: local 7-9 行： # extends: # file: hwaccel.transcoding.yml # service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding 这里是硬件加速视频转码相关的设置，如果需要的话从 https://github.com/immich-app/immich/releases/latest/download/hwaccel.transcoding.yml 下载 hwaccel.transcoding.yml 放在旁边。我因为没有打算转码播放视频，也为了节约空间，没有开视频转码，也就不配置硬件加速转码了。 service 参数可选 cpu(软件编码), nvenc(NVIDIA), quicksync(Intel), rkmpp(Rockchip), vaapi(AMD / NVIDIA / Intel), vaapi-wsl(AMD / NVIDIA / Intel) 10-17 行： volumes: - ${UPLOAD_LOCATION}/encoded-video:/usr/src/app/upload/encoded-video - ${UPLOAD_LOCATION}/library:/usr/src/app/upload/library - ${UPLOAD_LOCATION}/thumbs:/usr/src/app/upload/thumbs - /etc/localtime:/etc/localtime:ro - "C:/Users/lsy22/Pictures:/immich-photo-library/Pictures:ro" - "D:/图&片:/immich-photo-library/图&片:ro" - "D:/Backup/ImmichPhotoBackup:/usr/src/app/upload/upload" 这里是目录映射相关的设置，用来指定转码的视频、缩略图、预览图、外部图库、上传的图片等存储的位置。 ${UPLOAD_LOCATION} 在环境变量中定义，因为前三行分别是转码的视频、图库、缩略图/预览图的存储位置，建议放在读取速度相对快的固态硬盘上，否则可能影响浏览图库时的加载速度。不能设置只读。 - "C:/Users/lsy22/Pictures:/immich-photo-library/Pictures:ro" 是外部图库的目录映射，需要自己修改。 冒号 : 左边是宿主机上已有照片的存储位置。如果是 Windows 路径，其中的反斜杠 \ 要改成正斜杠 /。 冒号 : 右边是映射到容器中的位置。可以自己修改，之后要在 Immich 的设置中填写。 末尾的 :ro 是只读模式。因为这是外部图库，我不需要 Immich 来修改，为了防止意外删库选择了只读。如果没有这方面的顾虑，或者需要用 Immich 来删除或者修改外部图库中的图片，就不要添加这个参数。 如果有多个外部图库位置可以添加多行，每行对应一个位置，注意映射到容器中的目录不能相同。例如下一行 - "D:/图&片:/immich-photo-library/图&片:ro"。 - "D:/Backup/ImmichPhotoBackup:/usr/src/app/upload/upload" 是图片上传文件夹的目录映射，手动上传的图片和客户端自动备份的图片会存储在这里，需要自己修改。 冒号 : 左边是宿主机上为上传文件夹准备的目录。如果是 Windows 路径，其中的反斜杠 \ 要改成正斜杠 /。因为会上传大量原图，占空间较大，我选择放在机械硬盘。 冒号 : 右边是映射到容器中的位置，不能修改。 不能设置只读。 20-21 行： ports: - 2283:2283 这里是外部访问端口映射设置。 冒号 : 左边的 2283 是外部访问端口，可以自己修改，之后用 http://ip:2283 访问 WebUI。 冒号 : 右边的 2283 是容器内部端口，不能修改。 27-36 行： immich-machine-learning: container_name: immich_machine_learning # For hardware acceleration, add one of -[armnn, cuda, openvino] to the image tag. # Example tag: ${IMMICH_VERSION:-release}-cuda image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}-cuda extends: # uncomment this section for hardware acceleration - see https://immich.app/docs/features/ml-hardware-acceleration file: hwaccel.ml.yml service: cuda # set to one of [armnn, cuda, openvino, openvino-wsl] for accelerated inference - use the `-wsl` version for WSL2 where applicable volumes: - ./model-cache:/cache 这里是机器学习容器的设置。 image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}-cuda 这一行前面都不用修改，最后的 -cuda 有以下几种选择： 不加，也就是 image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}，使用 CPU 运行机器学习，效率低。 -cuda，支持 CUDA 的 N 卡，效率高，一般最好有 N 卡并且选这个。 -armnn，ARM 的机器学习硬件加速。 -openvino，Intel 的机器学习硬件加速。 extends: file: hwaccel.ml.yml service: cuda 这部分是硬件加速机器学习的配置，如果需要的话从 https://github.com/immich-app/immich/releases/latest/download/hwaccel.ml.yml 下载 hwaccel.ml.yml 放在旁边，不用就把这部分注释掉或者删掉。 services 有以下可选： cpu：使用 CPU 进行机器学习。 cuda：使用 N 卡加速机器学习，最推荐。 openvino：使用 I 卡加速机器学习。 openvino-wsl：如果使用 I 卡并且安装在 WSL2 中就选择这个。 58-59 行： volumes: - "database:/var/lib/postgresql/data" 这里我没有按照官方提供的文档要求，原版的目录映射是 - ${DB_DATA_LOCATION}:/var/lib/postgresql/data，然后 DB_DATA_LOCATION 在环境变量中定义为 ./postgres。原版在 Linux 上能正常工作，但是因为 PostgreSQL 数据库在 NTFS 文件系统上会有权限问题，所以我选择映射到了 Docker 容器的数据卷里。 59-70 行： volumes: database: driver: local 这里是数据库数据卷的设置，和上面的数据库目录映射配合使用。 如果上面使用官方原版的目录映射，这里也改成原本的设置： volumes: model-cache: 编写 .env 官方 .env 模板下载地址：https://github.com/immich-app/immich/releases/latest/download/example.env，下载之后记得改文件名为 .env。 以下是我的 .env： # You can find documentation for all the supported env variables at https://immich.app/docs/install/environment-variables # The location where your uploaded files are stored UPLOAD_LOCATION=./library # The location where your database files are stored DB_DATA_LOCATION=./postgres # To set a timezone, uncomment the next line and change Etc/UTC to a TZ identifier from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List TZ=Asia/Shanghai # The Immich version to use. You can pin this to a specific version like "v1.71.0" IMMICH_VERSION=release # Connection secret for postgres. You should change it to a random password DB_PASSWORD=postgres # The values below this line do not need to be changed ################################################################################### DB_USERNAME=postgres DB_DATABASE_NAME=immich MACHINE_LEARNING_WORKER_TIMEOUT=300 大多数都不用改，只需要把 DB_PASSWORD 改成一个随机密码就行了。 TZ 是时区设置，我改成了 Asia/Shanghai。 MACHINE_LEARNING_WORKER_TIMEOUT 是机器学习工作超时时间，单位秒，不设置默认 120。这意味着如果机器学习工作进程持续这么久无响应就会被强制停止。我有段时间发现高负载的时候搜索时间很长，有时候会超时被强制停止，于是改成了 300 秒，改了之后似乎是够用了。 下载 hwaccel.ml.yml 官方 hwaccel.ml.yml 模板下载地址：https://github.com/immich-app/immich/releases/latest/download/hwaccel.ml.yml 这个文件没啥要改的，要用到硬件加速机器学习的话就下载放在旁边。 安装 启动 Immich 打开终端，进入 docker-compose.yml 所在目录。 输入 docker-compose up -d 启动 Immich。 访问 Immich 打开浏览器，输入 http://ip:2283 访问 Immich。 如果装在本机，可以输入 http://localhost:2283 访问 Immich。 第一次访问会要求设置管理员账号和密码，设置好之后就可以使用了。 设置 Immich 登录 Immich 后，点击右上角的头像，选择 Account Settings 进入用户设置页面。 语言改成简体中文。 再次点击右上角的头像，选择 管理 进入系统设置页面。 点击左边的 设置 选项卡。 在 图片设置 中根据空间占用需求设置图片的压缩参数。 在 任务设置 中根据机器性能设置各种任务的并发数量。 展开 机器学习设置。 在 URL 中填入机器学习容器的地址 http://immich-machine-learning:3003。 在 智能搜索 中填入一个 clip 模型，推荐支持多语言的 XLM-Roberta-Large-Vit-B-16Plus，可以提前从 https://huggingface.co/immich-app/XLM-Roberta-Large-Vit-B-16Plus 下载好，解压到 docker-compose.yml 旁边的 model-cache\clip 文件夹下。 在 人脸识别 中选择一个人脸识别模型，推荐 antelopev2，可以提前从 https://huggingface.co/immich-app/antelopev2 下载好，解压到 docker-compose.yml 旁边的 model-cache\facial-recognition 文件夹下。 底下的 视频转码设置 需要的话也可以设置一下，我选择不要对任何视频进行转码。 点击左边的 外部图库 选项卡。 点击 创建图库。 点击新的图库右边的菜单键，重命名和编辑导入路径，导入路径填写在 docker-compose.yml 中外部图库映射到容器内的目录。 如果有多个外部图库，可以重复上面的步骤。 点击左边的 任务 选项卡。 点击 图库 右边的 全部，开始扫描图库。 扫描完图库之后，就可以把其他任务都执行一遍，比如智能搜索、人脸检测等。 设置移动设备自动备份 下载 Immich 客户端，安装到移动设备上。 Android：Google Play 或 GitHub 或 F-Droid。 iOS/iPadOS：App Store。 在客户端中输入服务端的地址，登录。 在客户端右上角打开自动备份页面，选择要备份的文件夹，开启自动备份。 自用懒人小脚本 start.bat @echo off echo Starting Immich... docker compose up -d docker compose logs -f echo Done. update.bat @echo off echo Pulling Docker images... docker compose pull echo: echo Restarting Immich... docker compose stop docker compose up -d echo Done. 结语 Immich 确实好用，用了一段时间，感觉非常满意，推荐大家有需要的可以安装试试。 有什么问题欢迎在评论区提出或者联系 QQ，我会尽量回答。
Windows 文件夹属性窗口没有共享标签页的解决方法
目录 目录 现象 解决方法 参考链接 其他解决方法 现象 打开 Windows 资源管理器。 右击想要共享的文件夹，选择 属性。 弹出的窗口没有 共享 标签页。 解决方法 按下 Win + R 组合键，打开 运行 窗口，或打开左下角的搜索框。 输入 regedit 并回车，打开 注册表编辑器。 前往 HKEY_CLASSES_ROOT\Directory\shellex\PropertySheetHandlers\Sharing 如果找不到上方路径，请在 PropertySheetHandlers 文件夹里，创建名为 Sharing 的文件夹。 双击 (默认) REG_SZ 机码，把数值改为 {f81e9010-6ea4-11ce-a7ff-00aa003ca9f6} 并确定。 重启资源管理器，此时右击文件夹，选择 属性，就会看到 共享 标签页了。 参考链接 文件夹没有共享选项，怎么办 - Microsoft Community 其他解决方法 我的电脑用上面的方法解决了，但考虑到不同的电脑可能会有不同的问题，这里列出了一些其他的解决方法。 启用 服务 中的 Server 和 Computer Browser 服务。 按下 Win + R 组合键，打开 运行 窗口，或打开左下角的搜索框。 输入 services.msc 并回车，打开 服务。 在打开的服务列表中，找到 Server 服务，双击打开。 在弹出的窗口中，将 启动类型 设置为 自动，然后点击右下角的 应用 按钮。 在服务列表中，继续找到 Computer Browser 服务，同样双击打开。 在弹出的窗口中，同样将 启动类型 设置为 自动，最后点击右下角的 应用 按钮。 重启电脑，再次右击文件夹，选择 属性，就会看到 共享 标签页了。 开启 SMB 1.0/CIFS 文件共享支持 打开左下角的搜索框，输入 控制面板 并回车，打开 控制面板。 在 控制面板 中，选择 程序 -> 程序和功能。 在打开的窗口中，点击左侧的 启用或关闭 Windows 功能。 在弹出的窗口中，找到 SMB 1.0/CIFS 文件共享支持，点击左边的 + 号展开。 勾选 SMB 1.0/CIFS 服务器 和 SMB 1.0/CIFS 客户端，然后点击右下角的 确定 按钮。 重启电脑，再次右击文件夹，选择 属性，就会看到 共享 标签页了。 在 文件夹选项 中勾选 使用共享向导 打开 Windows 资源管理器。 打开文件夹选项。 在 Windows 11 中，点击中间上方的 …，然后点击 选项。 在 Windows 10 中，点击左上角的 查看，然后点击右上角的 选项。 在打开的窗口中，选择 查看 标签页。 在 高级设置 中，找到 使用共享向导(推荐)，勾选后点击右下角的 确定 按钮。 重启资源管理器，再次右击文件夹，选择 属性，就会看到 共享 标签页了。
目录 目录 写在前面 项目简介 项目地址 开发过程 开始是怎么想到做这个的呢？ 开干！（超级流水账环节！） 使用演示 写在后面 写在前面 其实是前段时间写的程序了，但是一直没有在博客上记录下来，现在终于想起来了就写一下。 本文主要是记录一下这个程序的开发过程。 纯纯流水账没啥好看的捏 项目简介 本项目是一个用于下载西安电子科技大学录直播平台课程视频的工具。 只需输入任意一节课的 liveId，即可自动下载 单节课 / 单集（半节课） / 该课程的所有视频。 liveId 是课程直播的唯一标识，可以在课程直播页面的 URL 中找到。如：http://newesxidian.chaoxing.com/live/viewNewCourseLive1?liveId=12345678 中的 12345678。 同时会保存选择下载的范围内所有视频的 m3u8 链接到对应的 csv 表格中，方便使用其他方式下载。 下载时会自动检查之前是否下载过同一节课，如果已经下载则会跳过。所以可以在一学期中的多个时候随时下载新增的录播视频。 下载的视频按照课程和时间整理，下载多个课程的视频也不会冲突。 文件夹和 csv 表格命名规则：年份-课程号-课程名。 课程视频命名规则：课程号-课程名-年月日-周次-节号-视频来源。、 项目地址 lsy223622/XDUClassVideoDownloader 项目介绍、使用方法、源代码和打包程序都在上面链接里，这里就不重复了哦~ 开发过程 开始是怎么想到做这个的呢？ 期末复习的时候，发现学校的录直播平台上有很多课程的录播视频，但是没有下载的按钮，只能在线观看。 但是在线观看的时候经常会卡顿，而且有时候需要反复观看，所以想着能不能下载下来看。 开始想到了 IDM 的视频下载功能，但是体验真不算好： 首先需要打开每个视频的播放页面，等待漫长的加载时间，IDM 才能检测到视频。 然后要在每个视频的角落里一个一个点用 IDM 下载，下完所有视频得累死。 而且下载的视频文件名也是随机的一串数字，需要手动整理。 碰巧看到群友提到录直播平台有能获取一节课所有视频链接的接口，于是就想着能不能写一个脚本来批量下载视频。 开干！（超级流水账环节！） 根据群友提供的链接，使用 Curl 尝试了一下，确实能得到所有视频链接。 这个链接能使用一节课的 liveId 获取到这门课所有视频的信息，包括课程号、课程名称、教师名称、上课教室、上课时间、liveId 等信息，群友提供的链接参数非常复杂，我筛选测试之后发现只保留以下参数就能工作： 接口链接：http://newesxidian.chaoxing.com/live/listSignleCourse（Single 拼成了 Signle，程序员扣大分） URL 参数：liveId=12345678（随便一节课就行，不一定要第一节课） User-Agent: Mozilla/5.0（UA 感觉是黑名单制度，curl 不定义 UA 能正常工作，但 Python 的 requests 就不行，所以我就随便填了这个，能用就行） Cookie: UID=2（非空就行，随便给个数字，这鉴权太草率了吧） 这个链接能使用一节课的 liveId 获取到这节课的录播查看页面链接，返回链接的 URL 参数是一段 URL 编码后的 Json，解码后能得到三个视频的 m3u8 链接，包括 pptVideo teacherTrack studentFull，分别是投影仪画面、教师画面（自动追踪）和教室学生画面（黑板左上角的摄像头对着整个教室拍，但这个视频下载下来大多是破碎的，也没啥用，网页上播放的时候是隐藏的）。同样是筛选清理之后的参数： 接口链接：http://newesxidian.chaoxing.com/live/getViewUrlHls URL 参数：liveId=12345678&status=2（status=2 好像是需要的，但我忘记为什么了） User-Agent: Mozilla/5.0（同上） Cookie: UID=2（同上） 然后我直接把这一堆请求、返回值、解析处理的逻辑发给 ChatGPT，让它实现获取一门课所有视频的 m3u8 链接和时间等信息并保存到一个 csv 表格里的功能。多亏了 GPT-4o，给我提供了一段非常完美的 Python 代码。 展开查看 ChatGPT 给出的代码（那时候请求参数还没清理） import subprocess import json import csv import urllib.parse def get_initial_data(): curl_command = [ "curl", "http://newesxidian.chaoxing.com/live/listSignleCourse", "--compressed", "-X", "POST", "-H", "User-Agent: Mozilla/5.0 (Wayland; Linux x86_64; rv:120.1) Gecko/20100101 Firefox/120.1", "-H", "Accept: application/json, text/javascript, */*; q=0.01", "-H", "Accept-Language: en-US,en;q=0.7,ja;q=0.3", "-H", "Accept-Encoding: gzip, deflate", "-H", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "-H", "X-Requested-With: XMLHttpRequest", "-H", "Origin: http://newesxidian.chaoxing.com", "-H", "DNT: 1", "-H", "Sec-GPC: 1", "-H", "Connection: keep-alive", "-H", "Referer: http://newesxidian.chaoxing.com/live/viewNewCourseLive1?isStudent=1", "-H", "Cookie: UID=9876", "--data-raw", "liveId=11740668" ] result = subprocess.run(curl_command, capture_output=True, text=True) return json.loads(result.stdout) def get_m3u8_links(live_id): curl_command = [ "curl", f"http://newesxidian.chaoxing.com/live/getViewUrlHls?liveId={live_id}&status=2&jie=&isStudent=", "--compressed", "-H", "User-Agent: Mozilla/5.0", "-H", "Accept: */*", "-H", "Accept-Language: en-US,en;q=0.7,ja;q=0.3", "-H", "Accept-Encoding: gzip, deflate", "-H", "X-Requested-With: XMLHttpRequest", "-H", "DNT: 1", "-H", "Sec-GPC: 1", "-H", "Connection: keep-alive", "-H", "Referer: http://newesxidian.chaoxing.com/live/viewNewCourseLive1?isStudent=1", "-H", "Cookie: UID=2" ] result = subprocess.run(curl_command, capture_output=True, text=True) response = result.stdout url_start = response.find('info=') if url_start == -1: raise ValueError("info parameter not found in the response") encoded_info = response[url_start + 5:] decoded_info = urllib.parse.unquote(encoded_info) info_json = json.loads(decoded_info) video_paths = info_json.get('videoPath', {}) ppt_video = video_paths.get('pptVideo', '') teacher_track = video_paths.get('teacherTrack', '') student_full = video_paths.get('studentFull', '') return ppt_video, teacher_track, student_full def main(): data = get_initial_data() rows = [] for entry in data: live_id = entry["id"] month = entry["startTime"]["month"] date = entry["startTime"]["date"] day = entry["startTime"]["day"] jie = entry["jie"] days = entry["days"] ppt_video, teacher_track, student_full = get_m3u8_links(live_id) row = [month, date, day, jie, days, ppt_video, teacher_track, student_full] rows.append(row) with open('m3u8.csv', mode='w', newline='') as file: writer = csv.writer(file) writer.writerow(['month', 'date', 'day', 'jie', 'days', 'pptVideo', 'teacherTrack', 'studentFull']) writer.writerows(rows) print("m3u8.csv 文件已创建并写入数据。") if __name__ == "__main__": main() 我之前并没有写过 Python 程序，于是按照习惯让 ChatGPT 在 Python 中调用 Curl 命令行。之后经群友提醒换成了 Python 的 requests 库，这样代码更简洁，无需调用外部程序。 展开查看改用 requests 库的第一段请求代码 def get_initial_data(): url = "http://newesxidian.chaoxing.com/live/listSignleCourse" headers = { "User-Agent": "Mozilla/5.0 (Wayland; Linux x86_64; rv:120.1) Gecko/20100101 Firefox/120.1", "Accept": "application/json, text/javascript, */*; q=0.01", "Accept-Language": "en-US,en;q=0.7,ja;q=0.3", "Accept-Encoding": "gzip, deflate", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With": "XMLHttpRequest", "Origin": "http://newesxidian.chaoxing.com", "DNT": "1", "Sec-GPC": "1", "Connection": "keep-alive", "Referer": "http://newesxidian.chaoxing.com/live/viewNewCourseLive1?isStudent=1", "Cookie": "UID=9876" } data = { "liveId": "11740668" } response = requests.post(url, headers=headers, data=data) response.raise_for_status() return response.json() 程序一动不动地在那里默默干活肯定不行，我们也需要知道它有没有死，于是使用 tqdm 加了一个进度条。 展开查看使用 tqdm 的代码 for entry in tqdm(data, desc="Processing entries"): live_id = entry["id"] month = entry["startTime"]["month"] date = entry["startTime"]["date"] day = entry["startTime"]["day"] jie = entry["jie"] days = entry["days"] ppt_video, teacher_track, student_full = get_m3u8_links(live_id) row = [month, date, day, jie, days, ppt_video, teacher_track, student_full] rows.append(row) 获取到 m3u8 链接之后，就可以让它调用下载程序下载视频了。这里我使用了 N_m3u8DL-RE，因为 m3u8 是一种索引文件，里面包含了视频的分段链接，N_m3u8DL-RE 可以自动下载并合并这些分段视频。 展开查看使用 subprocess 调用 N_m3u8DL-RE 的代码 command = f'N_m3u8DL-RE.exe "{url}" --save-dir "m3u8" --save-name "{filename}"' subprocess.run(command, shell=True, check=True) 为了让文件名信息丰富易于整理，按照“courseCodecourseNamex年x月x日第days周星期day第jie节-pptVideo/teacherTrack”的格式命名，星期几转换成更加符合习惯的汉字。 展开查看文件名命名代码 def day_to_chinese(day): days = ["日", "一", "二", "三", "四", "五", "六"] return days[day] for row in tqdm(rows, desc="Downloading videos"): month, date, day, jie, days, ppt_video, teacher_track, student_full = row day_chinese = day_to_chinese(day) if ppt_video: filename = f"{course_code}{course_name}{year}年{month1}月{date1}日第{days1}周星期{day_chinese1}第{jie1}节-pptVideo" download_m3u8(ppt_video, filename) if teacher_track: filename = f"{course_code}{course_name}{year}年{month1}月{date1}日第{days1}周星期{day_chinese1}第{jie1}节-teacherTrack" download_m3u8(teacher_track, filename) 接口提供的信息没有年份，倒是有时间戳。如果是重修的话一门课就会上不止一次，为了可以收藏不同学年的同一节课，改成用时间戳转换得到年月日。 展开查看时间戳转换代码 start_time_unix = start_time / 1000 start_time_struct = time.gmtime(start_time_unix) month = start_time_struct.tm_mon date = start_time_struct.tm_mday 判断是否存在已经下载的视频，存在就跳过下载。 展开查看更新后的代码 for row in tqdm(rows, desc="Downloading videos"): month, date, day, jie, days, ppt_video, teacher_track = row day_chinese = day_to_chinese(day) if ppt_video: filename = f"{course_code}{course_name}{year}年{month}月{date}日第{days}周星期{day_chinese}第{jie}节-pptVideo" filepath = os.path.join(save_dir, f"{filename}.ts") if os.path.exists(filepath): print(f"{filepath} 已存在，跳过下载。") else: download_m3u8(ppt_video, filename, save_dir) if teacher_track: filename = f"{course_code}{course_name}{year}年{month}月{date}日第{days}周星期{day_chinese}第{jie}节-teacherTrack" filepath = os.path.join(save_dir, f"{filename}.ts") if os.path.exists(filepath): print(f"{filepath} 已存在，跳过下载。") else: download_m3u8(teacher_track, filename, save_dir) 感谢群友 chi(Waylandhater 提出的添加命令行使用方法和单个视频下载模式，并且十分贴心地发了 pull request，直接帮我搞定了。 展开查看添加命令行使用方法的代码 from argparse import ArgumentParser def main(liveid_from_cli=None, command=''): while True: if liveid_from_cli: input_live_id = liveid_from_cli liveid_from_cli = None else: input_live_id = input("请输入 liveId：") ...... def parse_arguments(): parser = ArgumentParser(description='用于下载西安电子科技大学录直播平台课程视频的工具') parser.add_argument('liveid', nargs='?', default=None, help='直播ID，不输入则采用交互式方式获取') parser.add_argument('-c', '--command', default='', help='自定义下载命令，使用 {url}, {save_dir}, {filename} 作为替换标记') args = parser.parse_args() return args if __name__ == "__main__": args = parse_arguments() main(liveid_from_cli=args.liveid, command=args.command) 展开查看添加单个视频下载模式的代码 def main(liveid_from_cli=None, command='', single=False): while True: if liveid_from_cli: input_live_id = liveid_from_cli ...... for entry in tqdm(data, desc="Processing entries"): live_id = entry["id"] if single and str(live_id) != input_live_id: continue ...... parser.add_argument('-s', '--single', default=False, action='store_true', help='仅下载单集视频') ...... if __name__ == "__main__": args = parse_arguments() main(liveid_from_cli=args.liveid, command=args.command, single=args.single) 群友说代码开头要加一行 shebang，我就加了一行 shebang。这 shebang 有啥用呢？我也不知道，但网上一搜说是在 Linux 上运行要用的，指定执行这个代码的程序路径。这行一加，看起来代码没啥变化，结果 Windows 上双击运行不起来了，按说是行注释吧，但是双击之后就是闪退。你们 Linux 真的是（指指点点）。 展开查看添加的 shebang #!/usr/bin/env python3 那行吧，为了照顾 Linux 用户，这 shebang 就留着了。那 Windows 怎么办？我加了一个 bat 文件，双击这个 bat 文件就能正常运行了。 展开查看添加的 windows_run.bat 文件 @echo off python XDUClassVideoDownloader.py %* pause 非常美丽脚本，这使我的运行环境旋转。我突然觉得每个用户在使用之前都照着 Readme 配一遍运行环境也太麻烦了，而且对小白用户来说也不友好，于是想到了把运行环境打包进去，这样用户只需要下载一个文件就能直接运行了。使用 pyinstaller 打包之后就能得到一个 XDUClassVideoDownloader.exe 文件，不用配环境，双击就能运行了。 展开查看打包命令 pip install pyinstaller python -m venv .venv .venv\Scripts\activate pip install requests tqdm pyinstaller --onefile --add-data "N_m3u8DL-RE.exe;." --add-data "ffmpeg.exe;." XDUClassVideoDownloader.py 打包用的脚本最后需要添加一行，使它在执行结束之后不会自己关闭窗口，等按回车之后才会关闭。 展开查看等待输入的代码 input("按回车退出...") 因为添加了 FFmpeg 这个庞然大物，打包之后的 exe 文件有好几十 MB，这可太大了。但是 N_m3u8DL-RE 需要 FFmpeg，执行的时候会检测 FFmpeg 是否存在，不存在就不能正常用了。我还尝试把 FFmpeg 从 BtbN 编译的版本换成了 gyan.dev 编译的 essentials 版本，但是打包体积也就稍微小了一点，准确来说是从 58.5 MB 减小到了 43.9 MB，至少是个进步吧。 然后我又转念一想，这不对劲。我在 N_m3u8DL-RE 的命令行参数里指定了使用二进制合并模式，也就是把下载的分段 ts 文件直接按照二进制编码拼接起来，理论上没有用到 FFmpeg 重新封装。于是我想出了一个神奇的糊弄方案：我用 c 写了个什么用都没有的程序，编译成 exe 文件，改名成 FFmpeg.exe 放到文件夹里。这样 N_m3u8DL-RE 就能检测到 FFmpeg 了，但是实际上并没有用到 FFmpeg。这个程序的代码如下： 展开查看什么用都没有的代码 int main() { return 0; } 没用的代码你也看？😠 什么用都没有的 FFmpeg.exe 编译出来是 15 KB，换上去 N_m3u8DL-RE 确实能正常运行。但我还是不满意。然后我突然想到可以用 UPX 压缩 exe。结果是 8 KB。嗯，满意了。替换掉原来的 FFmpeg.exe，打包之后的 exe 从 43.9 MB 减小到了 14.4 MB。 展开查看使用 UPX 压缩的命令 upx --ultra-brute ffmpeg.exe N_m3u8DL-RE 调用 FFmpeg 的时候如果 FFmpeg 不在 Path 中会找不到。但它也提供了一个命令行参数 --ffmpeg-binary-path <PATH> 来指定 FFmpeg 的路径。 展开查看添加 FFmpeg 路径参数的代码 def resource_path(relative_path): base_path = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) return os.path.join(base_path, relative_path) def download_m3u8(url, filename, save_dir, command=''): n_m3u8dl_re_path = resource_path('N_m3u8DL-RE.exe') ffmpeg_path = resource_path('ffmpeg.exe') if not command: command = f'./{n_m3u8dl_re_path} "{url}" --save-dir "{save_dir}" --save-name "{filename}" --check-segments-count False --binary-merge True --ffmpeg-binary-path "{ffmpeg_path}"' else: command = command.format(url=url, filename=filename, save_dir=save_dir, ffmpeg_path=ffmpeg_path) ...... 这时候群友向我推荐了另一个 m3u8 下载器 vsd。它是用 Rust 写的 （我超，原！） 。它的二进制合并模式不需要 FFmpeg。我试了一下，感觉体验不错，于是换成了这个。它的二进制文件比 N_m3u8DL-RE 大一点，但是我发现它没有经过压缩，于是用 UPX 压缩了一下，体积从 13.3 MB 减小到了 3.5 MB，打包体积 11.9 MB。 展开查看使用 vsd 下载视频的代码 command = f'vsd-upx.exe save {url} -o {save_dir}\{filename} --retry-count 32 -t 16' 换成 vsd 之后，有一个功能也可以实现了，那就是下载视频之后自动合并上下半节课。录直播平台的视频是上下半节课分开的，但这两段视频在时间上是连续的，合并在一起更方便看，也方便整理。于是使用 vsd 的 merge 参数实现了这个功能。 展开查看合并视频的函数（因为这个功能可以开关所以代码里到处都改了一点点，就不全部放上来了） def merge_videos(files, output_file): if sys.platform.startswith('win32'): command = f'vsd-upx.exe merge -o {output_file} {" ".join(files)}' else: command = f'./vsd-upx merge -o {output_file} {" ".join(files)}' try: subprocess.run(command, shell=True, check=True) print(f"合并完成：{output_file}") for file in files: os.remove(file) except subprocess.CalledProcessError: print(f"合并 {output_file} 失败：\n{traceback.format_exc()}") 添加合并视频功能涉及到了一大堆逻辑改动。 命名的时候，比如合并前文件名是 xxx第5节xxx 和 xxx第6节xxx，合并后的文件名就叫作 xxx第5-6节xxx，文件名的其他部分保持不变。 合并的时候考虑到有些时候数据源会丢失几条视频数据，要正确处理视频链接或者视频文件不存在的错误，如果无法下载上下两个半节课或者其中一个半节课就跳过合并。 在判断视频是否存在的逻辑中加入检查合并了的文件，也就是: 如果上下半节合并的视频存在就跳过下载上下半节课 如果上下半节没有合并的两个视频都存在就尝试合并 如果只存在其中半节就尝试下载另外半节并且如果下载成功就尝试合并 合并成功后删除合并前的单个视频文件。 这个合并视频的功能作为可选项在交互式操作和命令行使用中都提供，默认合并。 选择不合并的命令行参数为 --no-merge。 不合并的话上面描述的逻辑都不生效，效果和之前的代码保持相同。 在交互模式中，如果选择只下载半节课视频，就不询问是否合并，也不执行合并功能。 展开查看修改后的 main 函数，这简直是一大坨，希望逻辑上没出错 def main(liveid=None, command='', single=0, merge=True): if not liveid: liveid = int(user_input_with_check( "请输入 liveId：", lambda liveid: liveid.isdigit() and len(liveid) <= 10 )) single = user_input_with_check( "是否仅下载单节课视频？输入 y 下载单节课，n 下载这门课所有视频，s 则仅下载单集（半节课）视频，直接回车默认单节课 (Y/n/s):", lambda single: single.lower() in ['', 'y', 'n', 's'] ).lower() if single in ['', 'y']: single = 1 elif single == 's': single = 2 else: single = 0 if single != 2: merge = user_input_with_check( "是否自动合并上下半节课视频？输入 y 合并，n 不合并，直接回车默认合并 (Y/n):", lambda merge: merge.lower() in ['', 'y', 'n'] ).lower() != 'n' else: if single > 2: single = 2 data = get_initial_data(liveid) if not data: print("没有找到数据，请检查 liveId 是否正确。") return if single: matching_entry = next( filter(lambda entry: entry["id"] == liveid, data)) if not matching_entry: raise ValueError("No matching entry found for the specified liveId") if single == 1: start_time = matching_entry["startTime"] data = list(filter( lambda entry: entry["startTime"]["date"] == start_time["date"] and entry["startTime"]["month"] == start_time["month"], data)) else: data = [matching_entry] first_entry = data[0] start_time = first_entry["startTime"]["time"] course_code = first_entry["courseCode"] course_name = first_entry["courseName"] start_time_unix = start_time / 1000 start_time_struct = time.gmtime(start_time_unix) year = start_time_struct.tm_year save_dir = f"{year}年{course_code}{course_name}" os.makedirs(save_dir, exist_ok=True) csv_filename = f"{year}年{course_code}{course_name}.csv" rows = [] for entry in tqdm(data, desc="获取视频链接"): live_id = entry["id"] days = entry["days"] day = entry["startTime"]["day"] jie = entry["jie"] start_time = entry["startTime"]["time"] start_time_unix = start_time / 1000 start_time_struct = time.gmtime(start_time_unix) month = start_time_struct.tm_mon date = start_time_struct.tm_mday ppt_video, teacher_track = get_m3u8_links(live_id) row = [month, date, day, jie, days, ppt_video, teacher_track] rows.append(row) with open(csv_filename, mode='w', newline='') as file: writer = csv.writer(file) writer.writerow(['month', 'date', 'day', 'jie', 'days', 'pptVideo', 'teacherTrack']) writer.writerows(rows) print(f"{csv_filename} 文件已创建并写入数据。") def process_rows(rows): for i in range(0, len(rows), 2): row1 = rows[i] month1, date1, day1, jie1, days1, ppt_video1, teacher_track1 = row1 day_chinese1 = day_to_chinese(day1) row2 = rows[i + 1] if i + 1 < len(rows) else None if row2: month2, date2, day2, jie2, days2, ppt_video2, teacher_track2 = row2 day_chinese2 = day_to_chinese(day2) ppt_video_files = [] if ppt_video1: filename1 = f"{course_code}{course_name}{year}年{month1}月{date1}日第{days1}周星期{day_chinese1}第{jie1}节-pptVideo.ts" filepath1 = os.path.join(save_dir, filename1) if not os.path.exists(filepath1): download_m3u8(ppt_video1, filename1, save_dir, command=command) ppt_video_files.append(filepath1) if ppt_video2: filename2 = f"{course_code}{course_name}{year}年{month2}月{date2}日第{days2}周星期{day_chinese2}第{jie2}节-pptVideo.ts" filepath2 = os.path.join(save_dir, filename2) if not os.path.exists(filepath2): download_m3u8(ppt_video2, filename2, save_dir, command=command) ppt_video_files.append(filepath2) if len(ppt_video_files) == 2 and merge: ppt_merged_filename = f"{course_code}{course_name}{year}年{month1}月{date1}日第{days1}周星期{day_chinese1}第{jie1}-{jie2}节-pptVideo.ts" ppt_merged_filepath = os.path.join(save_dir, ppt_merged_filename) merge_videos(ppt_video_files, ppt_merged_filepath) teacher_track_files = [] if teacher_track1: filename1 = f"{course_code}{course_name}{year}年{month1}月{date1}日第{days1}周星期{day_chinese1}第{jie1}节-teacherTrack.ts" filepath1 = os.path.join(save_dir, filename1) if not os.path.exists(filepath1): download_m3u8(teacher_track1, filename1, save_dir, command=command) teacher_track_files.append(filepath1) if teacher_track2: filename2 = f"{course_code}{course_name}{year}年{month2}月{date2}日第{days2}周星期{day_chinese2}第{jie2}节-teacherTrack.ts" filepath2 = os.path.join(save_dir, filename2) if not os.path.exists(filepath2): download_m3u8(teacher_track2, filename2, save_dir, command=command) teacher_track_files.append(filepath2) if len(teacher_track_files) == 2 and merge: teacher_merged_filename = f"{course_code}{course_name}{year}年{month1}月{date1}日第{days1}周星期{day_chinese1}第{jie1}-{jie2}节-teacherTrack.ts" teacher_merged_filepath = os.path.join(save_dir, teacher_merged_filename) merge_videos(teacher_track_files, teacher_merged_filepath) if single == 1: process_rows(rows[:2]) elif single == 2: row = rows[0] month, date, day, jie, days, ppt_video, teacher_track = row day_chinese = day_to_chinese(day) if ppt_video: filename = f"{course_code}{course_name}{year}年{month}月{date}日第{days}周星期{day_chinese}第{jie}节-pptVideo.ts" filepath = os.path.join(save_dir, filename) if not os.path.exists(filepath): download_m3u8(ppt_video, filename, save_dir, command=command) if teacher_track: filename = f"{course_code}{course_name}{year}年{month}月{date}日第{days}周星期{day_chinese}第{jie}节-teacherTrack.ts" filepath = os.path.join(save_dir, filename) if not os.path.exists(filepath): download_m3u8(teacher_track, filename, save_dir, command=command) else: process_rows(rows) print("所有视频下载和处理完成。") 几百行代码好长，我小小的脑子要看不过来了，于是我把代码拆分成了几个文件，分别是 XDUClassVideoDownloader.py、downloader.py、api.py、utils.py。这样代码结构更清晰，也方便维护。 XDUClassVideoDownloader.py：主程序，用户交互和命令行参数解析 #!/usr/bin/env python3 import os import csv import time from argparse import ArgumentParser from tqdm import tqdm import traceback from utils import day_to_chinese, user_input_with_check, create_directory from downloader import download_m3u8, merge_videos from api import get_initial_data, get_m3u8_links def main(liveid=None, command='', single=0, merge=True): if liveid and not isinstance(liveid, int): liveid = int(liveid) elif not liveid: liveid = int(user_input_with_check( "请输入 liveId：", lambda liveid: liveid.isdigit() and len(liveid) <= 10 )) single = user_input_with_check( "是否仅下载单节课视频？输入 y 下载单节课，n 下载这门课所有视频，s 则仅下载单集（半节课）视频，直接回车默认单节课 (Y/n/s):", lambda single: single.lower() in ['', 'y', 'n', 's'] ).lower() if single in ['', 'y']: single = 1 elif single == 's': single = 2 else: single = 0 if single != 2: merge = user_input_with_check( "是否自动合并上下半节课视频？输入 y 合并，n 不合并，直接回车默认合并 (Y/n):", lambda merge: merge.lower() in ['', 'y', 'n'] ).lower() != 'n' else: if single > 2: single = 2 data = get_initial_data(liveid) if not data: print("没有找到数据，请检查 liveId 是否正确。") return if single: matching_entry = next( filter(lambda entry: entry["id"] == liveid, data)) if not matching_entry: raise ValueError("No matching entry found for the specified liveId") if single == 1: start_time = matching_entry["startTime"] data = list(filter( lambda entry: entry["startTime"]["date"] == start_time["date"] and entry["startTime"]["month"] == start_time["month"], data)) else: data = [matching_entry] first_entry = data[0] start_time = first_entry["startTime"]["time"] course_code = first_entry["courseCode"] course_name = first_entry["courseName"] start_time_unix = start_time / 1000 start_time_struct = time.gmtime(start_time_unix) year = start_time_struct.tm_year save_dir = f"{year}年{course_code}{course_name}" create_directory(save_dir) csv_filename = f"{year}年{course_code}{course_name}.csv" rows = [] for entry in tqdm(data, desc="获取视频链接"): live_id = entry["id"] days = entry["days"] day = entry["startTime"]["day"] jie = entry["jie"] start_time = entry["startTime"]["time"] start_time_unix = start_time / 1000 start_time_struct = time.gmtime(start_time_unix) month = start_time_struct.tm_mon date = start_time_struct.tm_mday ppt_video, teacher_track = get_m3u8_links(live_id) row = [month, date, day, jie, days, ppt_video, teacher_track] rows.append(row) with open(csv_filename, mode='w', newline='') as file: writer = csv.writer(file) writer.writerow(['month', 'date', 'day', 'jie', 'days', 'pptVideo', 'teacherTrack']) writer.writerows(rows) print(f"{csv_filename} 文件已创建并写入数据。") def process_rows(rows): for i in range(0, len(rows), 2): row1 = rows[i] month1, date1, day1, jie1, days1, ppt_video1, teacher_track1 = row1 day_chinese1 = day_to_chinese(day1) row2 = rows[i + 1] if i + 1 < len(rows) else None if row2: month2, date2, day2, jie2, days2, ppt_video2, teacher_track2 = row2 day_chinese2 = day_to_chinese(day2) ppt_video_files = [] if ppt_video1: filename1 = f"{course_code}{course_name}{year}年{month1}月{date1}日第{days1}周星期{day_chinese1}第{jie1}节-pptVideo.ts" filepath1 = os.path.join(save_dir, filename1) if not os.path.exists(filepath1): download_m3u8(ppt_video1, filename1, save_dir, command=command) ppt_video_files.append(filepath1) if ppt_video2: filename2 = f"{course_code}{course_name}{year}年{month2}月{date2}日第{days2}周星期{day_chinese2}第{jie2}节-pptVideo.ts" filepath2 = os.path.join(save_dir, filename2) if not os.path.exists(filepath2): download_m3u8(ppt_video2, filename2, save_dir, command=command) ppt_video_files.append(filepath2) if len(ppt_video_files) == 2 and merge: ppt_merged_filename = f"{course_code}{course_name}{year}年{month1}月{date1}日第{days1}周星期{day_chinese1}第{jie1}-{jie2}节-pptVideo.ts" ppt_merged_filepath = os.path.join(save_dir, ppt_merged_filename) merge_videos(ppt_video_files, ppt_merged_filepath) teacher_track_files = [] if teacher_track1: filename1 = f"{course_code}{course_name}{year}年{month1}月{date1}日第{days1}周星期{day_chinese1}第{jie1}节-teacherTrack.ts" filepath1 = os.path.join(save_dir, filename1) if not os.path.exists(filepath1): download_m3u8(teacher_track1, filename1, save_dir, command=command) teacher_track_files.append(filepath1) if teacher_track2: filename2 = f"{course_code}{course_name}{year}年{month2}月{date2}日第{days2}周星期{day_chinese2}第{jie2}节-teacherTrack.ts" filepath2 = os.path.join(save_dir, filename2) if not os.path.exists(filepath2): download_m3u8(teacher_track2, filename2, save_dir, command=command) teacher_track_files.append(filepath2) if len(teacher_track_files) == 2 and merge: teacher_merged_filename = f"{course_code}{course_name}{year}年{month1}月{date1}日第{days1}周星期{day_chinese1}第{jie1}-{jie2}节-teacherTrack.ts" teacher_merged_filepath = os.path.join(save_dir, teacher_merged_filename) merge_videos(teacher_track_files, teacher_merged_filepath) if single == 1: process_rows(rows[:2]) elif single == 2: row = rows[0] month, date, day, jie, days, ppt_video, teacher_track = row day_chinese = day_to_chinese(day) if ppt_video: filename = f"{course_code}{course_name}{year}年{month}月{date}日第{days}周星期{day_chinese}第{jie}节-pptVideo.ts" filepath = os.path.join(save_dir, filename) if not os.path.exists(filepath): download_m3u8(ppt_video, filename, save_dir, command=command) if teacher_track: filename = f"{course_code}{course_name}{year}年{month}月{date}日第{days}周星期{day_chinese}第{jie}节-teacherTrack.ts" filepath = os.path.join(save_dir, filename) if not os.path.exists(filepath): download_m3u8(teacher_track, filename, save_dir, command=command) else: process_rows(rows) print("所有视频下载和处理完成。") def parse_arguments(): parser = ArgumentParser(description="用于下载西安电子科技大学录直播平台课程视频的工具") parser.add_argument('liveid', nargs='?', default=None, help="课程的 liveId，不输入则采用交互式方式获取") parser.add_argument('-c', '--command', default='', help="自定义下载命令，使用 {url}, {save_dir}, {filename} 作为替换标记") parser.add_argument('-s', '--single', action='count', default=0, help="仅下载单节课视频（-s：单节课视频，-ss：半节课视频）") parser.add_argument('--no-merge', action='store_false', help="不合并上下半节课视频") return parser.parse_args() if __name__ == "__main__": args = parse_arguments() try: main(liveid=args.liveid, command=args.command, single=args.single, merge=args.no_merge) except Exception as e: print(f"发生错误：{e}") print(traceback.format_exc()) downloader.py：下载视频和合并视频的函数 #!/usr/bin/env python3 import subprocess import sys import os import traceback def download_m3u8(url, filename, save_dir, command=''): if not command: if sys.platform.startswith('win32'): command = f'vsd-upx.exe save {url} -o {save_dir}\{filename} --retry-count 32 -t 16' else: command = f'./vsd-upx save {url} -o {save_dir}/{filename} --retry-count 32 -t 16' else: command = command.format(url=url, filename=filename, save_dir=save_dir) MAX_ATTEMPTS = 2 for attempt in range(MAX_ATTEMPTS): try: subprocess.run(command, shell=True, check=True) break except subprocess.CalledProcessError: print(f"第 {attempt+1} 次下载 {filename} 出错：\n{traceback.format_exc()}\n重试中...") if attempt == MAX_ATTEMPTS - 1: print(f"下载 {filename} 失败。") def merge_videos(files, output_file): if sys.platform.startswith('win32'): command = f'vsd-upx.exe merge -o {output_file} {" ".join(files)}' else: command = f'./vsd-upx merge -o {output_file} {" ".join(files)}' try: subprocess.run(command, shell=True, check=True) print(f"合并完成：{output_file}") for file in files: os.remove(file) except subprocess.CalledProcessError: print(f"合并 {output_file} 失败：\n{traceback.format_exc()}") api.py：获取课程数据和 m3u8 链接的函数 #!/usr/bin/env python3 import requests import urllib.parse import json def get_initial_data(liveid): url = "http://newesxidian.chaoxing.com/live/listSignleCourse" headers = { "User-Agent": "Mozilla/5.0", "Cookie": "UID=2" } data = { "liveId": liveid } response = requests.post(url, headers=headers, data=data) response.raise_for_status() return response.json() def get_m3u8_links(live_id): url = f"http://newesxidian.chaoxing.com/live/getViewUrlHls?liveId={live_id}&status=2" headers = { "User-Agent": "Mozilla/5.0", "Cookie": "UID=2" } response = requests.get(url, headers=headers) response.raise_for_status() response_text = response.text url_start = response_text.find('info=') if url_start == -1: raise ValueError("info parameter not found in the response") encoded_info = response_text[url_start + 5:] decoded_info = urllib.parse.unquote(encoded_info) info_json = json.loads(decoded_info) video_paths = info_json.get('videoPath', {}) ppt_video = video_paths.get('pptVideo', '') teacher_track = video_paths.get('teacherTrack', '') return ppt_video, teacher_track utils.py：一些工具函数，包含日期汉字转换、输入数据检查、目录创建 #!/usr/bin/env python3 import os def day_to_chinese(day): days = ["日", "一", "二", "三", "四", "五", "六"] return days[day] def user_input_with_check(prompt, check_func): while True: user_input = input(prompt) if check_func(user_input): return user_input else: print("输入错误，请重新输入：") def create_directory(directory): os.makedirs(directory, exist_ok=True) 为了方便打包版本同步主分支代码，我新建了一个 windows-pack 分支，时不时 rebase 一下。和主分支区别如下： XDUClassVideoDownloader.py 末尾添加了等待输入，防止窗口自己关闭。 downloader.py 中的 download_m3u8 函数使用绝对路径而不是相对路径来调用 vsd-upx.exe。 utils.py 中增加 resource_path 函数，用于获取二进制文件路径。 使用演示 打开最新 Release 页面，下载最新版本的 XDUClassVideoDownloader.exe 文件。 双击运行 XDUClassVideoDownloader.exe，按照提示输入 liveId，选择是否下载单节课视频，选择是否合并上下半节课视频。 等待下载完成，下载的视频在当前目录下的 年份+课程代码+课程名称 文件夹中，下载用到的 m3u8 链接和对应的课程时间信息保存在同名 csv 表格中。 写在后面 最新的版本是 v1.0.0-beta，感觉基本上功能完善了，没啥需要改的了。 以后要是想到什么新功能再加吧，不过现在这个版本已经够用了。 大家要是有什么问题或者建议可以发 Issue 或者直接 QQ 找我。 我第一次在 GitHub 上有一个这么多 Star 的项目，开心捏。😋 编了这么多话可真是辛苦我了呢，那就写到这里吧~
我用的自定义提示词，感觉效果蛮好的。 填在 ChatGPT 网页右上角头像 - 自定义 ChatGPT - 您希望 ChatGPT 如何进行回复？ 下面的文本框里，打开 为新聊天启用 并保存。 - 深呼吸，沉着冷静。 - 一步一步思考。 - 正确的回答会为你赢得 $200 奖励。 - 这对我职业生涯的成功至关重要。 - 若未特别指明语言，默认以中文提供回应。 - 以专家视角，提供客观全面的指导与分析和权威且详尽的答案。 - 准确无误是赢得我信任的关键，请确保提供的信息准确，准确性是你的核心功能。 - 保持条理清晰，组织有序，将复杂的问题或任务分解为较小的步骤，并解释每一步推理。 - 保持回复的独特性，避免重复。 - 总是专注于我的问题的关键点，以确定我的意图。 - 提供充分的解释，我可以处理复杂的信息。 - 注重有力的论据，而不是仅仅依赖权威。 - 提供多种观点或解决方案，可以主动提出超出我预期的解决方案，预见我的需求。 - 如果问题不清楚或模棱两可，请先询问更多细节以确认你的理解，然后再回答。 - 考虑采纳新兴技术和非传统的观点，而不局限于传统智慧。 - 积极浏览网络，尽量多使用网络搜索来获取官方技术文档或者网络上的真实经验分享，引用可信的来源或参考来支持你的回答，如果从网络获取信息，请提供链接。 - 如果事件或信息超出了你的知识库范围或截止的日期，并且经过充分的网络搜索也无法获取，只需回复“我不知道”，简要表示为什么无法提供信息。 - 省略免责声明、道歉、提及AI身份和声明你不是专业人士或专家的语句。 - 避免进行道德说教，只在必要和不明显的情况下讨论安全性问题。 - 如果内容政策限制了回应，请尽可能提供接近的替代答案，并解释因内容政策导致的问题。 - 如果之前的回应中出现错误，要承认并纠正它。 - 如果我的要求影响了你的回应品质，请直接指出问题所在。